Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions AspNet.Security.OAuth.Providers.sln
Original file line number Diff line number Diff line change
Expand Up @@ -221,6 +221,7 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "docs", "docs", "{C2CA4B38-A
docs\workweixin.md = docs\workweixin.md
docs\xumm.md = docs\xumm.md
docs\zendesk.md = docs\zendesk.md
docs\linear.md = docs\linear.md
EndProjectSection
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "AspNet.Security.OAuth.Basecamp", "src\AspNet.Security.OAuth.Basecamp\AspNet.Security.OAuth.Basecamp.csproj", "{42306484-B2BF-4B52-B950-E0CDFA58B02A}"
Expand Down Expand Up @@ -318,6 +319,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AspNet.Security.OAuth.GitCo
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AspNet.Security.OAuth.Atlassian", "src\AspNet.Security.OAuth.Atlassian\AspNet.Security.OAuth.Atlassian.csproj", "{D2110C1B-6FE1-4D9A-81ED-93FB2AC85049}"
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
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Expand Down Expand Up @@ -740,6 +743,10 @@ Global
{D2110C1B-6FE1-4D9A-81ED-93FB2AC85049}.Debug|Any CPU.Build.0 = Debug|Any CPU
{D2110C1B-6FE1-4D9A-81ED-93FB2AC85049}.Release|Any CPU.ActiveCfg = Release|Any CPU
{D2110C1B-6FE1-4D9A-81ED-93FB2AC85049}.Release|Any CPU.Build.0 = Release|Any CPU
{B1167108-CA36-4C6B-85B0-1C7F5A24E4A4}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{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
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
Expand Down Expand Up @@ -855,6 +862,7 @@ Global
{F3E62C24-5F82-4CF5-A994-0E10D04FB495} = {C1352FD3-AE8B-43EE-B45B-F6E0B3FBAC6D}
{668833D5-DB6A-475F-B0FD-A03462B037B8} = {C1352FD3-AE8B-43EE-B45B-F6E0B3FBAC6D}
{D2110C1B-6FE1-4D9A-81ED-93FB2AC85049} = {C1352FD3-AE8B-43EE-B45B-F6E0B3FBAC6D}
{B1167108-CA36-4C6B-85B0-1C7F5A24E4A4} = {C1352FD3-AE8B-43EE-B45B-F6E0B3FBAC6D}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {C7B54DE2-6407-4802-AD9C-CE54BF414C8C}
Expand Down
1 change: 1 addition & 0 deletions docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ covered by the section above.
| Instagram | _Optional_ | [Documentation](instagram.md "Instagram provider documentation") |
| KOOK | _Optional_ | [Documentation](kook.md "KOOK provider documentation") |
| Line | _Optional_ | [Documentation](line.md "Line provider documentation") |
| Linear | _Optional_ | [Documentation](linear.md "Linear provider documentation") |
| LinkedIn | _Optional_ | [Documentation](linkedin.md "LinkedIn provider documentation") |
| Odnoklassniki | _Optional_ | [Documentation](odnoklassniki.md "Odnoklassniki provider documentation") |
| Okta | **Required** | [Documentation](okta.md "Okta provider documentation") |
Expand Down
26 changes: 26 additions & 0 deletions docs/linear.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
# Integrating the Linear Provider

## Example

```csharp
services.AddAuthentication(options => /* Auth configuration */)
.AddLinear(options =>
{
options.ClientId = configuration["Linear:ClientId"] ?? string.Empty;
options.ClientSecret = configuration["Linear:ClientSecret"] ?? string.Empty;

// 'read' scope is added by default. Add additional scopes required
// options.Scope.Add("write");

// Additional authorization parameters can also be added
// options.AdditionalAuthorizationParameters.Add("prompt", "consent");
})
```

## Required Additional Settings

_None._

## Optional Settings

_None._
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<TargetFrameworks>$(DefaultNetCoreTargetFramework)</TargetFrameworks>
Comment thread
jerriep marked this conversation as resolved.
</PropertyGroup>

<PropertyGroup>
<Description>ASP.NET Core security middleware enabling Linear authentication.</Description>
<Authors>Jerrie Pelser</Authors>
<PackageTags>aspnetcore;authentication;linear;oauth;security</PackageTags>
</PropertyGroup>

<ItemGroup>
<FrameworkReference Include="Microsoft.AspNetCore.App" />
<PackageReference Include="JetBrains.Annotations" PrivateAssets="All" />
</ItemGroup>

</Project>
22 changes: 22 additions & 0 deletions src/AspNet.Security.OAuth.Linear/LinearAuthenticationConstants.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
/*
* 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.Linear;

/// <summary>
/// Contains constants specific to the <see cref="LinearAuthenticationHandler"/>.
/// </summary>
public static class LinearAuthenticationConstants
{
public static class Claims
{
public const string OrganizationId = "urn:linear:organization_id";

public const string OrganizationName = "urn:linear:organization_name";

public const string OrganizationUrlKey = "urn:linear:organization_urlkey";
}
}
43 changes: 43 additions & 0 deletions src/AspNet.Security.OAuth.Linear/LinearAuthenticationDefaults.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
/*
* 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.Linear;

/// <summary>
/// Default values used by the Linear authentication middleware.
/// </summary>
public static class LinearAuthenticationDefaults
{
/// <summary>
/// Default value for the <see cref="AuthenticationScheme.Name"/>.
/// </summary>
public const string AuthenticationScheme = "Linear";

/// <summary>
/// Default value for the <see cref="AuthenticationScheme.DisplayName"/>.
/// </summary>
public static readonly string DisplayName = "Linear";

/// <summary>
/// Default value for the <see cref="AuthenticationSchemeOptions.ClaimsIssuer"/>.
/// </summary>
public static readonly string Issuer = "Linear";

/// <summary>
/// Default value for the <see cref="RemoteAuthenticationOptions.CallbackPath"/>.
/// </summary>
public static readonly string CallbackPath = "/signin-linear";

/// <summary>
/// Default value for the <see cref="OAuthOptions.AuthorizationEndpoint"/>.
/// </summary>
public static readonly string AuthorizationEndpoint = "https://linear.app/oauth/authorize";

/// <summary>
/// Default value for the <see cref="OAuthOptions.TokenEndpoint"/>.
/// </summary>
public static readonly string TokenEndpointFormat = "https://api.linear.app/oauth/token";
}
71 changes: 71 additions & 0 deletions src/AspNet.Security.OAuth.Linear/LinearAuthenticationExtensions.cs
Original file line number Diff line number Diff line change
@@ -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.Linear;

namespace Microsoft.Extensions.DependencyInjection;

public static class LinearAuthenticationExtensions
{
/// <summary>
/// Adds <see cref="LinearAuthenticationHandler"/> to the specified
/// <see cref="AuthenticationBuilder"/>, which enables Linear authentication capabilities.
/// </summary>
/// <param name="builder">The authentication builder.</param>
/// <returns>A reference to this instance after the operation has completed.</returns>
public static AuthenticationBuilder AddLinear([NotNull] this AuthenticationBuilder builder)
{
return builder.AddLinear(LinearAuthenticationDefaults.AuthenticationScheme, options => { });
}

/// <summary>
/// Adds <see cref="LinearAuthenticationHandler"/> to the specified
/// <see cref="AuthenticationBuilder"/>, which enables Linear authentication capabilities.
/// </summary>
/// <param name="builder">The authentication builder.</param>
/// <param name="configuration">The delegate used to configure the Linear options.</param>
/// <returns>A reference to this instance after the operation has completed.</returns>
public static AuthenticationBuilder AddLinear(
[NotNull] this AuthenticationBuilder builder,
[NotNull] Action<LinearAuthenticationOptions> configuration)
{
return builder.AddLinear(LinearAuthenticationDefaults.AuthenticationScheme, configuration);
}

/// <summary>
/// Adds <see cref="LinearAuthenticationHandler"/> to the specified
/// <see cref="AuthenticationBuilder"/>, which enables Linear authentication capabilities.
/// </summary>
/// <param name="builder">The authentication builder.</param>
/// <param name="scheme">The authentication scheme associated with this instance.</param>
/// <param name="configuration">The delegate used to configure the Linear options.</param>
/// <returns>The <see cref="AuthenticationBuilder"/>.</returns>
public static AuthenticationBuilder AddLinear(
[NotNull] this AuthenticationBuilder builder,
[NotNull] string scheme,
[NotNull] Action<LinearAuthenticationOptions> configuration)
{
return builder.AddLinear(scheme, LinearAuthenticationDefaults.DisplayName, configuration);
}

/// <summary>
/// Adds <see cref="LinearAuthenticationHandler"/> to the specified
/// <see cref="AuthenticationBuilder"/>, which enables Linear authentication capabilities.
/// </summary>
/// <param name="builder">The authentication builder.</param>
/// <param name="scheme">The authentication scheme associated with this instance.</param>
/// <param name="caption">The optional display name associated with this instance.</param>
/// <param name="configuration">The delegate used to configure the Linear options.</param>
/// <returns>The <see cref="AuthenticationBuilder"/>.</returns>
public static AuthenticationBuilder AddLinear(
[NotNull] this AuthenticationBuilder builder,
[NotNull] string scheme,
[NotNull] string caption,
[NotNull] Action<LinearAuthenticationOptions> configuration)
{
return builder.AddOAuth<LinearAuthenticationOptions, LinearAuthenticationHandler>(scheme, caption, configuration);
}
}
102 changes: 102 additions & 0 deletions src/AspNet.Security.OAuth.Linear/LinearAuthenticationHandler.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
/*
* 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.Net.Mime;
using System.Security.Claims;
using System.Text;
using System.Text.Encodings.Web;
using System.Text.Json;
using System.Text.Json.Serialization;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;

namespace AspNet.Security.OAuth.Linear;

public partial class LinearAuthenticationHandler(
[NotNull] IOptionsMonitor<LinearAuthenticationOptions> options,
[NotNull] ILoggerFactory logger,
[NotNull] UrlEncoder encoder) : OAuthHandler<LinearAuthenticationOptions>(options, logger, encoder)
{
private const string UserQuery = """
query {
viewer {
id
name
email
organization {
id
name
urlKey
}
}
}
""";

protected override async Task<AuthenticationTicket> CreateTicketAsync(
[NotNull] ClaimsIdentity identity,
[NotNull] AuthenticationProperties properties,
[NotNull] OAuthTokenResponse tokens)
{
using var request = new HttpRequestMessage(HttpMethod.Post, "https://api.linear.app/graphql");

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Linear does not have a user info endpoint and instead has a single GraphQL endpoint for all API requests. To retrieve the user info, you must build a GraphQL query and send it to the GraphQL endpoint.

Therefore, I did not consider it appropriate to use the usual pattern of setting and using the Options.UserInformationEndpoint property in the back channel. I am happy to hear suggestions on this.

request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", tokens.AccessToken);
request.Content = new StringContent(
JsonSerializer.Serialize(new GraphqlQuery(UserQuery), AppJsonSerializerContext.Default.GraphqlQuery),
Encoding.UTF8,
MediaTypeNames.Application.Json);

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 Linear.");
}

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.GetProperty("data").GetProperty("viewer"));
context.RunClaimActions();

await Events.CreatingTicket(context);
return new AuthenticationTicket(context.Principal!, context.Properties, Scheme.Name);
}

[JsonSerializable(typeof(GraphqlQuery))]
internal sealed partial class AppJsonSerializerContext : JsonSerializerContext;

internal sealed record GraphqlQuery([property: JsonPropertyName("query")] string Query);
Comment thread
martincostello marked this conversation as resolved.
Outdated

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);
}
}
34 changes: 34 additions & 0 deletions src/AspNet.Security.OAuth.Linear/LinearAuthenticationOptions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
/*
* 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;
using static AspNet.Security.OAuth.Linear.LinearAuthenticationConstants;

namespace AspNet.Security.OAuth.Linear;

/// <summary>
/// Defines a set of options used by <see cref="LinearAuthenticationHandler"/>.
/// </summary>
public class LinearAuthenticationOptions : OAuthOptions
{
public LinearAuthenticationOptions()
{
ClaimsIssuer = LinearAuthenticationDefaults.Issuer;
CallbackPath = LinearAuthenticationDefaults.CallbackPath;

AuthorizationEndpoint = LinearAuthenticationDefaults.AuthorizationEndpoint;
TokenEndpoint = LinearAuthenticationDefaults.TokenEndpointFormat;

Scope.Add("read");

ClaimActions.MapJsonKey(ClaimTypes.NameIdentifier, "id");
ClaimActions.MapJsonKey(ClaimTypes.Name, "name");
ClaimActions.MapJsonKey(ClaimTypes.Email, "email");
ClaimActions.MapJsonSubKey(Claims.OrganizationId, "organization", "id");
ClaimActions.MapJsonSubKey(Claims.OrganizationName, "organization", "name");
ClaimActions.MapJsonSubKey(Claims.OrganizationUrlKey, "organization", "urlKey");
}
}
32 changes: 32 additions & 0 deletions test/AspNet.Security.OAuth.Providers.Tests/Linear/LinearTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
/*
* 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.Linear;

public class LinearTests(ITestOutputHelper outputHelper) : OAuthTests<LinearAuthenticationOptions>(outputHelper)
{
public override string DefaultScheme => LinearAuthenticationDefaults.AuthenticationScheme;

protected internal override void RegisterAuthentication(AuthenticationBuilder builder)
{
builder.AddLinear(options =>
{
ConfigureDefaults(builder, options);
});
}

[Theory]
[InlineData(ClaimTypes.NameIdentifier, "the-user-id")]
[InlineData(ClaimTypes.Name, "Marty McFly")]
[InlineData(ClaimTypes.Email, "marty@hillvalley.com")]
[InlineData(LinearAuthenticationConstants.Claims.OrganizationId, "the-org-id")]
[InlineData(LinearAuthenticationConstants.Claims.OrganizationName, "Hill Valley Ventures")]
[InlineData(LinearAuthenticationConstants.Claims.OrganizationUrlKey, "hill-valley")]
public async Task Can_Sign_In_Using_Linear(string claimType, string claimValue)
{
await AuthenticateUserAndAssertClaimValue(claimType, claimValue);
}
}
Loading