From 7ede38d0abdb5cfc408b68c76197fc91ee8ae912 Mon Sep 17 00:00:00 2001
From: Matthew Strasiotto <39424834+matthewstrasiotto@users.noreply.github.com>
Date: Fri, 27 May 2022 01:41:43 +1000
Subject: [PATCH] Implement canonical linking
---
SSO-Auth/Api/RequestHelpers.cs | 62 ++++++
SSO-Auth/Api/SSOController.cs | 280 +++++++++++++++++++++++--
SSO-Auth/Config/PluginConfiguration.cs | 41 ++++
SSO-Auth/WebResponse.cs | 40 +++-
4 files changed, 400 insertions(+), 23 deletions(-)
create mode 100644 SSO-Auth/Api/RequestHelpers.cs
diff --git a/SSO-Auth/Api/RequestHelpers.cs b/SSO-Auth/Api/RequestHelpers.cs
new file mode 100644
index 0000000..af29df9
--- /dev/null
+++ b/SSO-Auth/Api/RequestHelpers.cs
@@ -0,0 +1,62 @@
+// The following code is a derivative work of the code from the Jellyfin project,
+// which is licensed GPLv2. This code therefore is also licensed under the terms
+// of the GNU Public License, verison 2.
+// https://github.com/jellyfin/jellyfin/blob/a60cb280a3d31ba19ffb3a94cf83ef300a7473b7/Jellyfin.Api/Helpers/RequestHelpers.cs#L63-L77
+
+// Use of this relatively small snippet complies with fair use
+// See https://www.gnu.org/licenses/gpl-faq.en.html#SourceCodeInDocumentation
+// These helpers were not published within a Nuget package, so it was neccessary to re-implement.
+
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Net.Mime;
+using System.Text.RegularExpressions;
+using System.Threading.Tasks;
+using IdentityModel.OidcClient;
+using Jellyfin.Data.Entities;
+using Jellyfin.Data.Enums;
+using Jellyfin.Plugin.SSO_Auth.Config;
+using Jellyfin.Plugin.SSO_Auth.Helpers;
+using MediaBrowser.Controller.Authentication;
+using MediaBrowser.Controller.Library;
+using MediaBrowser.Controller.Net;
+using MediaBrowser.Controller.Session;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.Extensions.Logging;
+using Newtonsoft.Json;
+using Newtonsoft.Json.Linq;
+
+namespace Jellyfin.Plugin.SSO_Auth.Helpers;
+
+///
+/// Request Extensions.
+///
+public static class RequestHelpers
+{
+ ///
+ /// Checks if the user can update an entry.
+ ///
+ /// Instance of the interface.
+ /// The .
+ /// The user id.
+ /// Whether to restrict the user preferences.
+ /// A whether the user can update the entry.
+ internal static async Task AssertCanUpdateUser(IAuthorizationContext authContext, HttpRequest requestContext, Guid userId, bool restrictUserPreferences)
+ {
+ var auth = await authContext.GetAuthorizationInfo(requestContext).ConfigureAwait(false);
+
+ var authenticatedUser = auth.User;
+
+ // If they're going to update the record of another user, they must be an administrator
+ if ((!userId.Equals(auth.UserId) && !authenticatedUser.HasPermission(PermissionKind.IsAdministrator))
+ || (restrictUserPreferences && !authenticatedUser.EnableUserPreferenceAccess))
+ {
+ return false;
+ }
+
+ return true;
+ }
+}
diff --git a/SSO-Auth/Api/SSOController.cs b/SSO-Auth/Api/SSOController.cs
index ad974cb..4baacfa 100644
--- a/SSO-Auth/Api/SSOController.cs
+++ b/SSO-Auth/Api/SSOController.cs
@@ -8,12 +8,15 @@ using IdentityModel.OidcClient;
using Jellyfin.Data.Entities;
using Jellyfin.Data.Enums;
using Jellyfin.Plugin.SSO_Auth.Config;
+using Jellyfin.Plugin.SSO_Auth.Helpers;
using MediaBrowser.Controller.Authentication;
using MediaBrowser.Controller.Library;
+using MediaBrowser.Controller.Net;
using MediaBrowser.Controller.Session;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
+using Microsoft.AspNetCore.Routing;
using Microsoft.Extensions.Logging;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
@@ -29,6 +32,7 @@ public class SSOController : ControllerBase
{
private readonly IUserManager _userManager;
private readonly ISessionManager _sessionManager;
+ private readonly IAuthorizationContext _authContext;
private readonly ILogger _logger;
private static readonly IDictionary StateManager = new Dictionary();
@@ -37,11 +41,13 @@ public class SSOController : ControllerBase
///
/// Instance of the interface.
/// Instance of the interface.
+ /// Instance of the interface.
/// Instance of the interface.
- public SSOController(ILogger logger, ISessionManager sessionManager, IUserManager userManager)
+ public SSOController(ILogger logger, ISessionManager sessionManager, IUserManager userManager, IAuthorizationContext authContext)
{
_sessionManager = sessionManager;
_userManager = userManager;
+ _authContext = authContext;
_logger = logger;
_logger.LogInformation("SSO Controller initialized");
}
@@ -196,9 +202,12 @@ public class SSOController : ControllerBase
}
}
+ bool isLinking = StateManager[state].IsLinking;
+
if (StateManager[state].Valid)
{
- return Content(WebResponse.Generator(data: state, provider: provider, baseUrl: GetRequestBase(), mode: "OID"), MediaTypeNames.Text.Html);
+ _logger.LogInformation($"Is request linking: {isLinking}");
+ return Content(WebResponse.Generator(data: state, provider: provider, baseUrl: GetRequestBase(), mode: "OID", isLinking: isLinking), MediaTypeNames.Text.Html);
}
else
{
@@ -220,9 +229,10 @@ public class SSOController : ControllerBase
/// Initiates the login flow for OpenID. This redirects the user to the auth provider.
///
/// The name of the provider.
+ /// Whether or not this request is to link accounts (Rather than authenticate).
/// An asynchronous result for the authentication.
[HttpGet("OID/p/{provider}")]
- public async Task OidChallenge(string provider)
+ public async Task OidChallenge(string provider, [FromQuery] bool isLinking = false)
{
Invalidate();
OidConfig config;
@@ -249,6 +259,9 @@ public class SSOController : ControllerBase
var oidcClient = new OidcClient(options);
var state = await oidcClient.PrepareLoginAsync().ConfigureAwait(false);
StateManager.Add(state.State, new TimedAuthorizeState(state, DateTime.Now));
+
+ // Track whether this is a linking request or not.
+ StateManager[state.State].IsLinking = isLinking;
return Redirect(state.StartUrl);
}
@@ -331,7 +344,9 @@ public class SSOController : ControllerBase
{
if (kvp.Value.State.State.Equals(response.Data) && kvp.Value.Valid)
{
- var authenticationResult = await Authenticate(kvp.Value.Username, kvp.Value.Admin, config.EnableAuthorization, config.EnableAllFolders, kvp.Value.Folders.ToArray(), response, config.DefaultProvider)
+ Guid userId = await CreateCanonicalLinkAndUserIfNotExist("oid", provider, kvp.Value.Username);
+
+ var authenticationResult = await Authenticate(userId, kvp.Value.Admin, config.EnableAuthorization, config.EnableAllFolders, kvp.Value.Folders.ToArray(), response, config.DefaultProvider)
.ConfigureAwait(false);
return Ok(authenticationResult);
}
@@ -345,9 +360,13 @@ public class SSOController : ControllerBase
/// This is the callback for the SAML flow. This creates a webpage to complete auth.
///
/// The provider that is calling back.
+ ///
+ /// RelayState given in the original saml request. If it is equal to "linking",
+ /// We consider this to be a linking request.
+ ///
/// A webpage that will complete the client-side flow.
[HttpPost("SAML/p/{provider}")]
- public ActionResult SamlPost(string provider)
+ public ActionResult SamlPost(string provider, [FromQuery] string relayState = null)
{
SamlConfig config;
try
@@ -359,13 +378,19 @@ public class SSOController : ControllerBase
return BadRequest("No matching provider found");
}
+ bool isLinking = relayState == "linking";
+
+ _logger.LogInformation(
+ $"SAML request has relayState of {relayState}");
+
if (config.Enabled)
{
var samlResponse = new Response(config.SamlCertificate, Request.Form["SAMLResponse"]);
+
// If no roles are configured, don't use RBAC
if (config.Roles.Length == 0)
{
- return Content(WebResponse.Generator(data: Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes(samlResponse.Xml)), provider: provider, baseUrl: GetRequestBase(), mode: "SAML"), MediaTypeNames.Text.Html);
+ return Content(WebResponse.Generator(data: Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes(samlResponse.Xml)), provider: provider, baseUrl: GetRequestBase(), mode: "SAML", isLinking: isLinking), MediaTypeNames.Text.Html);
}
// Check if user is allowed to log in based on roles
@@ -375,7 +400,7 @@ public class SSOController : ControllerBase
{
if (allowedRole.Equals(role))
{
- return Content(WebResponse.Generator(data: Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes(samlResponse.Xml)), provider: provider, baseUrl: GetRequestBase(), mode: "SAML"), MediaTypeNames.Text.Html);
+ return Content(WebResponse.Generator(data: Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes(samlResponse.Xml)), provider: provider, baseUrl: GetRequestBase(), mode: "SAML", isLinking: isLinking), MediaTypeNames.Text.Html);
}
}
}
@@ -395,9 +420,10 @@ public class SSOController : ControllerBase
/// Initializes the SAML flow. This will redirect the user to the SAML provider.
///
/// The provider to being the flow with.
+ /// Whether this flow intends to link an account, or initiate auth.
/// A redirect to the SAML provider's auth page.
[HttpGet("SAML/p/{provider}")]
- public RedirectResult SamlChallenge(string provider)
+ public RedirectResult SamlChallenge(string provider, [FromQuery] bool isLinking = false)
{
SamlConfig config;
try
@@ -411,11 +437,17 @@ public class SSOController : ControllerBase
if (config.Enabled)
{
+ string relayState = null;
+ if (isLinking)
+ {
+ relayState = "linking";
+ }
+
var request = new AuthRequest(
config.SamlClientId,
GetRequestBase() + "/sso/SAML/p/" + provider);
- return Redirect(request.GetRedirectUrl(config.SamlEndpoint));
+ return Redirect(request.GetRedirectUrl(config.SamlEndpoint, relayState));
}
throw new ArgumentException("Provider does not exist");
@@ -520,7 +552,9 @@ public class SSOController : ControllerBase
}
}
- var authenticationResult = await Authenticate(samlResponse.GetNameID(), isAdmin, config.EnableAuthorization, config.EnableAllFolders, folders.ToArray(), response, config.DefaultProvider)
+ Guid userId = await CreateCanonicalLinkAndUserIfNotExist("saml", provider, samlResponse.GetNameID());
+
+ var authenticationResult = await Authenticate(userId, isAdmin, config.EnableAuthorization, config.EnableAllFolders, folders.ToArray(), response, config.DefaultProvider)
.ConfigureAwait(false);
return Ok(authenticationResult);
}
@@ -544,28 +578,223 @@ public class SSOController : ControllerBase
return Ok();
}
+ private SerializableDictionary GetCanonicalLinks(string mode, string provider)
+ {
+ SerializableDictionary links = null;
+
+ switch (mode.ToLower())
+ {
+ case "saml":
+ links = SSOPlugin.Instance.Configuration.SamlConfigs[provider].CanonicalLinks;
+ break;
+ case "oid":
+ links = SSOPlugin.Instance.Configuration.OidConfigs[provider].CanonicalLinks;
+ break;
+ default:
+ throw new ArgumentException($"{mode} is not a valid choice between 'saml' and 'oid'");
+ }
+
+ if (links == null)
+ {
+ links = new SerializableDictionary();
+ }
+
+ return links;
+ }
+
+ private async Task CreateCanonicalLinkAndUserIfNotExist(string mode, string provider, string canonicalName)
+ {
+ Guid userId = Guid.Empty;
+ try
+ {
+ userId = GetCanonicalLink(mode, provider, canonicalName);
+ }
+ catch (KeyNotFoundException)
+ {
+ userId = Guid.Empty;
+ }
+
+ if (userId == Guid.Empty)
+ {
+ _logger.LogInformation("SSO user link doesn't exist, creating...");
+ User user = null;
+ user = _userManager.GetUserByName(canonicalName);
+
+ if (user == null)
+ {
+ _logger.LogInformation($"SSO user {canonicalName} doesn't exist, creating...");
+ user = await _userManager.CreateUserAsync(canonicalName).ConfigureAwait(false);
+ user.AuthenticationProviderId = GetType().FullName;
+ }
+
+ userId = user.Id;
+
+ CreateCanonicalLink(mode, provider, userId, canonicalName);
+ }
+
+ return userId;
+ }
+
+ private Guid GetCanonicalLink(string mode, string provider, string canonicalName)
+ {
+ SerializableDictionary links = null;
+ Guid userId = Guid.Empty;
+
+ links = GetCanonicalLinks(mode, provider);
+
+ userId = links[canonicalName];
+
+ return userId;
+ }
+
+ ///
+ /// Removes a user from SSO auth and switches it back to another auth provider. Requires administrator privileges.
+ ///
+ /// The mode of the function; SAML or OID.
+ /// The name of the provider to link to a jellyfin account.
+ /// The user ID within jellyfin to link to the provider.
+ /// The client information to authenticate the user with.
+ /// Whether this API endpoint succeeded.
+ [Authorize(Policy = "DefaultAuthorization")]
+ [HttpPost("{mode}/Link/{provider}/{jellyfinUserId}")]
+ [Consumes(MediaTypeNames.Application.Json)]
+ [Produces(MediaTypeNames.Application.Json)]
+ public async Task AddCanonicalLink([FromRoute] string mode, [FromRoute] string provider, [FromRoute] Guid jellyfinUserId, [FromBody] AuthResponse authResponse)
+ {
+ if (!await RequestHelpers.AssertCanUpdateUser(_authContext, HttpContext.Request, jellyfinUserId, true).ConfigureAwait(false))
+ {
+ return StatusCode(StatusCodes.Status403Forbidden, "User is not allowed to link SSO providers.");
+ }
+
+ switch (mode.ToLower())
+ {
+ case "saml":
+ return SamlLink(provider, jellyfinUserId, authResponse);
+ case "oid":
+ return OidLink(provider, jellyfinUserId, authResponse);
+ default:
+ throw new ArgumentException($"{mode} is not a valid choice between 'saml' and 'oid'");
+ }
+ }
+
+ ///
+ /// Validate a saml link request and create the link if it is valid.
+ ///
+ /// The provider to authenticate against.
+ ///
+ /// The ID of the account to be linked to the provider.
+ /// Must be performed by this user, or an admin.
+ ///
+ /// The data passed to the client to ensure it is the right one.
+ /// JSON for the client to populate information with.
+ [Consumes(MediaTypeNames.Application.Json)]
+ [Produces(MediaTypeNames.Application.Json)]
+ private ActionResult SamlLink(string provider, Guid jellyfinUserId, AuthResponse response)
+ {
+ SamlConfig config;
+ try
+ {
+ config = SSOPlugin.Instance.Configuration.SamlConfigs[provider];
+ }
+ catch (KeyNotFoundException)
+ {
+ return BadRequest("No matching provider found");
+ }
+
+ var samlResponse = new Response(config.SamlCertificate, response.Data);
+ // TODO: Does saml response require further validation?
+
+ string providerUserId = samlResponse.GetNameID();
+
+ return CreateCanonicalLink("saml", provider, jellyfinUserId, providerUserId);
+ }
+
+ ///
+ /// Validate an OIDC link request and create the link if it is valid.
+ ///
+ /// The provider to authenticate against.
+ ///
+ /// The ID of the account to be linked to the provider.
+ /// Must be performed by this user, or an admin.
+ ///
+ /// The data passed to the client to ensure it is the right one.
+ /// JSON for the client to populate information with.
+ [Consumes(MediaTypeNames.Application.Json)]
+ [Produces(MediaTypeNames.Application.Json)]
+ private ActionResult OidLink(string provider, Guid jellyfinUserId, AuthResponse response)
+ {
+ OidConfig config;
+ try
+ {
+ config = SSOPlugin.Instance.Configuration.OidConfigs[provider];
+ }
+ catch (KeyNotFoundException)
+ {
+ return BadRequest("No matching provider found");
+ }
+
+ foreach (var kvp in StateManager)
+ {
+ if (kvp.Value.State.State.Equals(response.Data) && kvp.Value.Valid)
+ {
+ string providerUserId = kvp.Value.Username;
+ return CreateCanonicalLink("oid", provider, jellyfinUserId, providerUserId);
+ }
+ }
+
+ return Problem("Something went wrong!");
+ }
+
+ private ActionResult CreateCanonicalLink(string mode, string provider, [FromRoute] Guid jellyfinUserId, string providerUserId)
+ {
+ SerializableDictionary links = null;
+ try
+ {
+ links = GetCanonicalLinks(mode, provider);
+ }
+ catch (KeyNotFoundException)
+ {
+ return BadRequest("No matching provider found");
+ }
+
+ links[providerUserId] = jellyfinUserId;
+ UpdateCanonicalLinkConfig(links, mode, provider);
+
+ return NoContent();
+ }
+
+ private OkResult UpdateCanonicalLinkConfig(SerializableDictionary links, string mode, string provider)
+ {
+ var configuration = SSOPlugin.Instance.Configuration;
+ switch (mode.ToLower())
+ {
+ case "saml":
+ configuration.SamlConfigs[provider].CanonicalLinks = links;
+ break;
+ case "oid":
+ configuration.OidConfigs[provider].CanonicalLinks = links;
+ break;
+ default:
+ throw new ArgumentException($"{mode} is not a valid choice between 'saml' and 'oid'");
+ }
+
+ SSOPlugin.Instance.UpdateConfiguration(configuration);
+ return Ok();
+ }
+
///
/// Authenticates the user with the given information.
///
- /// The username of the user to authenticate.
+ /// The user id of the user to authenticate.
/// Determines whether this user is an administrator.
/// Determines whether RBAC is used for this user.
/// Determines whether all folders are enabled.
/// Determines which folders should be enabled for this client.
/// The client information to authenticate the user with.
/// The default provider of the user to be set after logging in.
- private async Task Authenticate(string username, bool isAdmin, bool enableAuthorization, bool enableAllFolders, string[] enabledFolders, AuthResponse authResponse, string defaultProvider)
+ private async Task Authenticate(Guid userId, bool isAdmin, bool enableAuthorization, bool enableAllFolders, string[] enabledFolders, AuthResponse authResponse, string defaultProvider)
{
- User user = null;
- user = _userManager.GetUserByName(username);
-
- if (user == null)
- {
- _logger.LogInformation("SSO user doesn't exist, creating...");
- user = await _userManager.CreateUserAsync(username).ConfigureAwait(false);
- user.AuthenticationProviderId = GetType().FullName;
- }
-
+ User user = _userManager.GetUserById(userId);
if (enableAuthorization)
{
user.SetPermission(PermissionKind.IsAdministrator, isAdmin);
@@ -670,6 +899,7 @@ public class TimedAuthorizeState
Created = created;
Valid = false;
Admin = false;
+ IsLinking = false;
}
///
@@ -697,6 +927,12 @@ public class TimedAuthorizeState
///
public bool Admin { get; set; }
+ ///
+ /// Gets or sets a value indicating whether the state is
+ /// tied to a linking flow (instead of a login flow).
+ ///
+ public bool IsLinking { get; set; }
+
///
/// Gets or sets the folders the user is allowed access to.
///
diff --git a/SSO-Auth/Config/PluginConfiguration.cs b/SSO-Auth/Config/PluginConfiguration.cs
index 6c7bd79..bf9a43b 100644
--- a/SSO-Auth/Config/PluginConfiguration.cs
+++ b/SSO-Auth/Config/PluginConfiguration.cs
@@ -1,3 +1,4 @@
+using System;
using System.Collections.Generic;
using System.Xml.Serialization;
@@ -36,6 +37,8 @@ public class PluginConfiguration : MediaBrowser.Model.Plugins.BasePluginConfigur
[XmlRoot("PluginConfiguration")]
public class SamlConfig
{
+ private SerializableDictionary _canonicalLinks;
+
///
/// Gets or sets the SAML information endpoint.
///
@@ -97,6 +100,24 @@ public class SamlConfig
/// Gets or sets the default provider the user after logging in with SSO.
///
public string DefaultProvider { get; set; }
+
+ ///
+ /// Gets or sets a mapping of canonical names from the provider to jellyfin user ids.
+ ///
+ [XmlElement("CanonicalLinks")]
+ public SerializableDictionary CanonicalLinks
+ {
+ get
+ {
+ if (_canonicalLinks == null)
+ {
+ return new SerializableDictionary();
+ }
+
+ return _canonicalLinks;
+ }
+ set => _canonicalLinks = value;
+ }
}
///
@@ -105,6 +126,8 @@ public class SamlConfig
[XmlRoot("PluginConfiguration")]
public class OidConfig
{
+ private SerializableDictionary _canonicalLinks;
+
///
/// Gets or sets the OpenID well-known information endpoint.
///
@@ -176,6 +199,24 @@ public class OidConfig
/// Gets or sets the default provider the user after logging in with SSO.
///
public string DefaultProvider { get; set; }
+
+ ///
+ /// Gets or sets a mapping of canonical names from the provider to jellyfin user ids.
+ ///
+ [XmlElement("CanonicalLinks")]
+ public SerializableDictionary CanonicalLinks
+ {
+ get
+ {
+ if (_canonicalLinks == null)
+ {
+ return new SerializableDictionary();
+ }
+
+ return _canonicalLinks;
+ }
+ set => _canonicalLinks = value;
+ }
}
///
diff --git a/SSO-Auth/WebResponse.cs b/SSO-Auth/WebResponse.cs
index 10520a8..8d5611f 100644
--- a/SSO-Auth/WebResponse.cs
+++ b/SSO-Auth/WebResponse.cs
@@ -412,10 +412,46 @@ const sleep = (milliseconds) => {
/// The name of the provider to callback to.
/// The base URL of the Jellyfin installation.
/// The mode of the function; SAML or OID.
+ /// Whether or not this request is to link accounts (Rather than authenticate).
/// A string with the HTML to serve to the client.
- public static string Generator(string data, string provider, string baseUrl, string mode)
+ public static string Generator(string data, string provider, string baseUrl, string mode, bool isLinking = false)
{
return Base + @"
+async function link(request) {
+ const jfCredentialsString = localStorage.getItem(""jellyfin_credentials"");
+
+ if (jfCredentialsString == null) return;
+
+ const jfCredentials = JSON.parse(jfCredentialsString);
+ const jfUser = jfCredentials['Servers'][0]['UserId'];
+ const jfToken = jfCredentials['Servers'][0]['AccessToken'];
+
+ if (jfUser == null) return;
+ if (jfToken == null) return;
+
+ const url = '" + $"{baseUrl}/sso/{mode}/Link/{provider}/" + @"' + jfUser;
+
+ return new Promise(resolve => {
+ var xhr = new XMLHttpRequest();
+ xhr.open('POST', url, true);
+ xhr.setRequestHeader('Content-Type', 'application/json');
+ xhr.setRequestHeader('Accept', 'application/json');
+
+ xhr.setRequestHeader(
+ 'X-Emby-Authorization',
+ `MediaBrowser Client=""${request.appName}"",Device=""${request.deviceName}"",DeviceId=""${request.deviceId}"",Version=""${request.appVersion}"",Token=""${jfToken}""`)
+
+ xhr.onload = function(e) {
+ resolve(xhr.response);
+ };
+ xhr.onerror = function (e) {
+ console.log(e);
+ resolve(undefined);
+ };
+ xhr.send(JSON.stringify(request));
+ })
+}
+
async function main() {
var data = '" + data + @"';
while (localStorage.getItem(""_deviceId2"") == null ||
@@ -431,6 +467,8 @@ async function main() {
var request = {deviceId, appName, appVersion, deviceName, data};
+ if (" + $"{isLinking}".ToLower() + @") await link(request);
+
var url = '" + baseUrl + "/sso/" + mode + "/Auth/" + provider + @"';
let response = await new Promise(resolve => {