diff --git a/AspNet.Security.OAuth.Providers.sln b/AspNet.Security.OAuth.Providers.sln
index b9ec15bed..6c017accf 100644
--- a/AspNet.Security.OAuth.Providers.sln
+++ b/AspNet.Security.OAuth.Providers.sln
@@ -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}"
@@ -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
@@ -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
@@ -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}
diff --git a/README.md b/README.md
index d0cf81be2..a51067286 100644
--- a/README.md
+++ b/README.md
@@ -201,6 +201,7 @@ If a provider you're looking for does not exist, consider making a PR to add one
| Kroger | [](https://www.nuget.org/packages/AspNet.Security.OAuth.Kroger/ "Download AspNet.Security.OAuth.Kroger from NuGet.org") | [](https://www.myget.org/feed/aspnet-contrib/package/nuget/AspNet.Security.OAuth.Kroger "Download AspNet.Security.OAuth.Kroger from MyGet.org") | [Documentation](https://developer.kroger.com/reference/#section/Authentication "Kroger developer documentation") |
| Lichess | [](https://www.nuget.org/packages/AspNet.Security.OAuth.Lichess/ "Download AspNet.Security.OAuth.Lichess from NuGet.org") | [](https://www.myget.org/feed/aspnet-contrib/package/nuget/AspNet.Security.OAuth.Lichess "Download AspNet.Security.OAuth.Lichess from MyGet.org") | [Documentation](https://lichess.org/api#section/Authentication "Lichess developer documentation") |
| Line | [](https://www.nuget.org/packages/AspNet.Security.OAuth.Line/ "Download AspNet.Security.OAuth.Line from NuGet.org") | [](https://www.myget.org/feed/aspnet-contrib/package/nuget/AspNet.Security.OAuth.Line "Download AspNet.Security.OAuth.Line from MyGet.org") | [Documentation](https://developers.line.biz/en/docs/line-login/integrate-line-login "Line developer documentation") |
+| Linear | [](https://www.nuget.org/packages/AspNet.Security.OAuth.Linear/ "Download AspNet.Security.OAuth.Linear from NuGet.org") | [](https://www.myget.org/feed/aspnet-contrib/package/nuget/AspNet.Security.OAuth.Linear "Download AspNet.Security.OAuth.Linear from MyGet.org") | [Documentation](https://developers.linear.app/docs/oauth/authentication "Linear developer documentation") |
| LinkedIn | [](https://www.nuget.org/packages/AspNet.Security.OAuth.LinkedIn/ "Download AspNet.Security.OAuth.LinkedIn from NuGet.org") | [](https://www.myget.org/feed/aspnet-contrib/package/nuget/AspNet.Security.OAuth.LinkedIn "Download AspNet.Security.OAuth.LinkedIn from MyGet.org") | [Documentation](https://docs.microsoft.com/en-us/linkedin/shared/authentication/authentication "LinkedIn developer documentation") |
| MailChimp | [](https://www.nuget.org/packages/AspNet.Security.OAuth.MailChimp/ "Download AspNet.Security.OAuth.MailChimp from NuGet.org") | [](https://www.myget.org/feed/aspnet-contrib/package/nuget/AspNet.Security.OAuth.MailChimp "Download AspNet.Security.OAuth.MailChimp from MyGet.org") | [Documentation](https://developer.mailchimp.com/documentation/mailchimp/guides/how-to-use-oauth2/ "MailChimp developer documentation") |
| MailRu | [](https://www.nuget.org/packages/AspNet.Security.OAuth.MailRu/ "Download AspNet.Security.OAuth.MailRu from NuGet.org") | [](https://www.myget.org/feed/aspnet-contrib/package/nuget/AspNet.Security.OAuth.MailRu "Download AspNet.Security.OAuth.MailRu from MyGet.org") | [Documentation](https://o2.mail.ru/docs#web "MailRu developer documentation") |
diff --git a/docs/README.md b/docs/README.md
index 5a25cacdb..cef9dffa2 100644
--- a/docs/README.md
+++ b/docs/README.md
@@ -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") |
diff --git a/docs/linear.md b/docs/linear.md
new file mode 100644
index 000000000..05c4483f0
--- /dev/null
+++ b/docs/linear.md
@@ -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._
diff --git a/src/AspNet.Security.OAuth.Linear/AspNet.Security.OAuth.Linear.csproj b/src/AspNet.Security.OAuth.Linear/AspNet.Security.OAuth.Linear.csproj
new file mode 100644
index 000000000..c567439e2
--- /dev/null
+++ b/src/AspNet.Security.OAuth.Linear/AspNet.Security.OAuth.Linear.csproj
@@ -0,0 +1,21 @@
+
+
+
+ 9.2.0
+
+ true
+ $(DefaultNetCoreTargetFramework)
+
+
+
+ ASP.NET Core security middleware enabling Linear authentication.
+ Jerrie Pelser
+ aspnetcore;authentication;linear;oauth;security
+
+
+
+
+
+
+
+
diff --git a/src/AspNet.Security.OAuth.Linear/LinearAuthenticationConstants.cs b/src/AspNet.Security.OAuth.Linear/LinearAuthenticationConstants.cs
new file mode 100644
index 000000000..d24bfc64a
--- /dev/null
+++ b/src/AspNet.Security.OAuth.Linear/LinearAuthenticationConstants.cs
@@ -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;
+
+///
+/// Contains constants specific to the .
+///
+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";
+ }
+}
diff --git a/src/AspNet.Security.OAuth.Linear/LinearAuthenticationDefaults.cs b/src/AspNet.Security.OAuth.Linear/LinearAuthenticationDefaults.cs
new file mode 100644
index 000000000..ffca4ec0c
--- /dev/null
+++ b/src/AspNet.Security.OAuth.Linear/LinearAuthenticationDefaults.cs
@@ -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;
+
+///
+/// Default values used by the Linear authentication middleware.
+///
+public static class LinearAuthenticationDefaults
+{
+ ///
+ /// Default value for the .
+ ///
+ public const string AuthenticationScheme = "Linear";
+
+ ///
+ /// Default value for the .
+ ///
+ public static readonly string DisplayName = "Linear";
+
+ ///
+ /// Default value for the .
+ ///
+ public static readonly string Issuer = "Linear";
+
+ ///
+ /// Default value for the .
+ ///
+ public static readonly string CallbackPath = "/signin-linear";
+
+ ///
+ /// Default value for the .
+ ///
+ public static readonly string AuthorizationEndpoint = "https://linear.app/oauth/authorize";
+
+ ///
+ /// Default value for the .
+ ///
+ public static readonly string TokenEndpointFormat = "https://api.linear.app/oauth/token";
+}
diff --git a/src/AspNet.Security.OAuth.Linear/LinearAuthenticationExtensions.cs b/src/AspNet.Security.OAuth.Linear/LinearAuthenticationExtensions.cs
new file mode 100644
index 000000000..792af5dbc
--- /dev/null
+++ b/src/AspNet.Security.OAuth.Linear/LinearAuthenticationExtensions.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.Linear;
+
+namespace Microsoft.Extensions.DependencyInjection;
+
+public static class LinearAuthenticationExtensions
+{
+ ///
+ /// Adds to the specified
+ /// , which enables Linear authentication capabilities.
+ ///
+ /// The authentication builder.
+ /// A reference to this instance after the operation has completed.
+ public static AuthenticationBuilder AddLinear([NotNull] this AuthenticationBuilder builder)
+ {
+ return builder.AddLinear(LinearAuthenticationDefaults.AuthenticationScheme, options => { });
+ }
+
+ ///
+ /// Adds to the specified
+ /// , which enables Linear authentication capabilities.
+ ///
+ /// The authentication builder.
+ /// The delegate used to configure the Linear options.
+ /// A reference to this instance after the operation has completed.
+ public static AuthenticationBuilder AddLinear(
+ [NotNull] this AuthenticationBuilder builder,
+ [NotNull] Action configuration)
+ {
+ return builder.AddLinear(LinearAuthenticationDefaults.AuthenticationScheme, configuration);
+ }
+
+ ///
+ /// Adds to the specified
+ /// , which enables Linear authentication capabilities.
+ ///
+ /// The authentication builder.
+ /// The authentication scheme associated with this instance.
+ /// The delegate used to configure the Linear options.
+ /// The .
+ public static AuthenticationBuilder AddLinear(
+ [NotNull] this AuthenticationBuilder builder,
+ [NotNull] string scheme,
+ [NotNull] Action configuration)
+ {
+ return builder.AddLinear(scheme, LinearAuthenticationDefaults.DisplayName, configuration);
+ }
+
+ ///
+ /// Adds to the specified
+ /// , which enables Linear 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 Linear options.
+ /// The .
+ public static AuthenticationBuilder AddLinear(
+ [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.Linear/LinearAuthenticationHandler.cs b/src/AspNet.Security.OAuth.Linear/LinearAuthenticationHandler.cs
new file mode 100644
index 000000000..946476633
--- /dev/null
+++ b/src/AspNet.Security.OAuth.Linear/LinearAuthenticationHandler.cs
@@ -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 options,
+ [NotNull] ILoggerFactory logger,
+ [NotNull] UrlEncoder encoder) : OAuthHandler(options, logger, encoder)
+{
+ private const string UserQuery = """
+ query {
+ viewer {
+ id
+ name
+ email
+ organization {
+ id
+ name
+ urlKey
+ }
+ }
+ }
+ """;
+
+ protected override async Task CreateTicketAsync(
+ [NotNull] ClaimsIdentity identity,
+ [NotNull] AuthenticationProperties properties,
+ [NotNull] OAuthTokenResponse tokens)
+ {
+ using var request = new HttpRequestMessage(HttpMethod.Post, "https://api.linear.app/graphql");
+ 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))]
+ private sealed partial class AppJsonSerializerContext : JsonSerializerContext;
+
+ private sealed record GraphqlQuery([property: JsonPropertyName("query")] string Query);
+
+ 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.Linear/LinearAuthenticationOptions.cs b/src/AspNet.Security.OAuth.Linear/LinearAuthenticationOptions.cs
new file mode 100644
index 000000000..a2b7db78a
--- /dev/null
+++ b/src/AspNet.Security.OAuth.Linear/LinearAuthenticationOptions.cs
@@ -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;
+
+///
+/// Defines a set of options used by .
+///
+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");
+ }
+}
diff --git a/test/AspNet.Security.OAuth.Providers.Tests/Linear/LinearTests.cs b/test/AspNet.Security.OAuth.Providers.Tests/Linear/LinearTests.cs
new file mode 100644
index 000000000..2f65c755c
--- /dev/null
+++ b/test/AspNet.Security.OAuth.Providers.Tests/Linear/LinearTests.cs
@@ -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(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);
+ }
+}
diff --git a/test/AspNet.Security.OAuth.Providers.Tests/Linear/bundle.json b/test/AspNet.Security.OAuth.Providers.Tests/Linear/bundle.json
new file mode 100644
index 000000000..e40613f33
--- /dev/null
+++ b/test/AspNet.Security.OAuth.Providers.Tests/Linear/bundle.json
@@ -0,0 +1,37 @@
+{
+ "$schema": "https://raw.githubusercontent.com/justeat/httpclient-interception/master/src/HttpClientInterception/Bundles/http-request-bundle-schema.json",
+ "items": [
+ {
+ "comment": "https://developers.linear.app/docs/oauth/authentication",
+ "uri": "https://api.linear.app/oauth/token",
+ "method": "POST",
+ "contentFormat": "json",
+ "contentJson": {
+ "access_token": "00a21d8b0c4e2375114e49c067dfb81eb0d2076f48354714cd5df984d87b67cc",
+ "token_type": "Bearer",
+ "expires_in": 315705599,
+ "scope": "read"
+ }
+ },
+ {
+ "comment": "https://developers.linear.app/docs/graphql/working-with-the-graphql-api",
+ "uri": "https://api.linear.app/graphql",
+ "method": "POST",
+ "contentFormat": "json",
+ "contentJson": {
+ "data" : {
+ "viewer" : {
+ "id" : "the-user-id",
+ "name" : "Marty McFly",
+ "email" : "marty@hillvalley.com",
+ "organization" : {
+ "id" : "the-org-id",
+ "name" : "Hill Valley Ventures",
+ "urlKey" : "hill-valley"
+ }
+ }
+ }
+ }
+ }
+ ]
+}