Skip to content

JWT SSO integration for C#

S Varun edited this page Jun 17, 2026 · 2 revisions
// =============================================================================
//  NeetoJwtClient - NeetoAuth JWT / single sign-on (SSO) helper
// =============================================================================
//
//  Generates a short-lived (2-minute) ECDSA-SHA256 signed JWT and builds the
//  NeetoAuth login URL, so you can sign a user (or consumer) into a neeto
//  product (Desk, Cal, Form, Chat, KB) straight from a .NET app - no passwords.
//
// -----------------------------------------------------------------------------
//  Requirements
// -----------------------------------------------------------------------------
//   - Platform: Windows .NET / .NET Framework. Key loading uses Windows CNG
//     (CngKey.Import + ECDsaCng), which are Windows-only APIs.
//   - NuGet packages:
//       * System.IdentityModel.Tokens.Jwt   (JWT build + signing)
//       * BouncyCastle.Cryptography          (PEM / EC private-key parsing;
//         the older Portable.BouncyCastle package also works)
//   - An EC (elliptic-curve) private key in PEM format. Register the matching
//     public key in your NeetoAuth workspace settings.
//   - Adjust the namespace below to match your project if needed.
//
// -----------------------------------------------------------------------------
//  Quick start
// -----------------------------------------------------------------------------
//   // 1) Load the PEM key from config. Literal "\n" sequences are converted to
//   //    real newlines automatically, so single-line config values are fine.
//   string privateKeyPem = ConfigurationManager.AppSettings["NeetoPrivateKey"];
//
//   // 2a) USER scope - log a workspace user into a neeto app:
//   var client = new NeetoJwtClient(
//       email:         "agent@yourcompany.com",
//       workspace:     "spinkart",       // your neeto subdomain
//       privateKeyPem: privateKeyPem,
//       scope:         "user",           // optional; default. "team-member" is an alias for "user"
//       environment:   "production");    // production | staging | development
//
//   string loginUrl = client.GenerateLoginUrl("https://spinkart.neetodesk.com/admin");
//   // Redirect the browser to loginUrl to complete the login.
//
//   // 2b) CONSUMER scope - log an end customer into the consumer portal
//   //     (workspace may be null; it defaults to "app"):
//   var consumerClient = new NeetoJwtClient(
//       email:         "customer@example.com",
//       workspace:     null,
//       privateKeyPem: privateKeyPem,
//       scope:         "consumer");      // "customer" is an alias for "consumer"
//
//   string portalUrl =
//       consumerClient.GenerateLoginUrl("https://your-portal.neetodesk.com");
//
//   // 3) Just need the raw signed token (e.g. for an API call)?
//   string jwt = client.GenerateJwt();   // valid for 2 minutes
//
// -----------------------------------------------------------------------------
//  Security notes
// -----------------------------------------------------------------------------
//   - The private key never leaves the process; it is only used locally to sign.
//   - GenerateLoginUrl puts the JWT in the URL query string, so avoid logging
//     the full URL. The 2-minute expiry keeps the exposure window small.
// =============================================================================

using Microsoft.IdentityModel.Tokens;
using Org.BouncyCastle.Crypto;
using Org.BouncyCastle.Crypto.Parameters;
using Org.BouncyCastle.OpenSsl;
using Org.BouncyCastle.Pkcs;
using System;
using System.Collections.Generic;
using System.IdentityModel.Tokens.Jwt;
using System.IO;
using System.Linq;
using System.Security.Cryptography;
using System.Web;

namespace Infotems.BLL.Integrations.NeetoDesk
{

    public sealed class NeetoJwtClient
    {
        private const string UserScope = "user";
        private const string ConsumerScope = "consumer";

        private const string UserLoginPath = "/users/auth/jwt";
        private const string ConsumerLoginPath = "/consumers/auth/jwt";
        private const string ConsumerWorkspace = "app";

        private static readonly string[] CanonicalScopes = { UserScope, ConsumerScope };

        private static readonly Dictionary<string, string> ScopeAliases =
            new Dictionary<string, string>
            {
                { "team-member", UserScope },
                { "customer", ConsumerScope }
            };

        private readonly string _email;
        private readonly string _workspace;
        private readonly string _privateKeyPem;
        private readonly string _scope;
        private readonly string _environment;

        public NeetoJwtClient(
            string email,
            string workspace,
            string privateKeyPem,
            string scope = UserScope,
            string environment = "production")
        {
            if (string.IsNullOrWhiteSpace(email))
                throw new ArgumentException("Email is required.", nameof(email));

            if (string.IsNullOrWhiteSpace(scope))
                scope = UserScope;

            scope = NormalizeScope(scope);

            if (string.IsNullOrWhiteSpace(privateKeyPem))
                throw new ArgumentException("Private key is required.", nameof(privateKeyPem));

            if (string.IsNullOrWhiteSpace(workspace))
            {
                if (scope == ConsumerScope)
                {
                    workspace = ConsumerWorkspace;
                }
                else
                {
                    throw new ArgumentException("Workspace is required.", nameof(workspace));
                }
            }

            _email = email;
            _workspace = workspace;
            _privateKeyPem = privateKeyPem;
            _scope = scope;
            _environment = string.IsNullOrWhiteSpace(environment) ? "production" : environment;
        }

        private static string NormalizeScope(string scope)
        {
            if (CanonicalScopes.Contains(scope))
                return scope;

            if (ScopeAliases.TryGetValue(scope, out var canonicalScope))
                return canonicalScope;

            var acceptedScopes = string.Join(", ", CanonicalScopes.Concat(ScopeAliases.Keys));
            throw new ArgumentException($"Scope must be one of: {acceptedScopes}.", nameof(scope));
        }

        public string GenerateJwt()
        {
            var now = DateTimeOffset.UtcNow;
            var iat = now.ToUnixTimeSeconds();
            var exp = now.AddMinutes(2).ToUnixTimeSeconds();

            using (var ecdsa = LoadEcdsaPrivateKey(_privateKeyPem))
            {
                var securityKey = new ECDsaSecurityKey(ecdsa);
                var credentials = new SigningCredentials(
                    securityKey,
                    SecurityAlgorithms.EcdsaSha256
                );

                var header = new JwtHeader(credentials);

                var payload = new JwtPayload
                {
                    { "email", _email },
                    { "workspace", _workspace },
                    { "scope", _scope },
                    { "iat", iat },
                    { "exp", exp }
                };

                var token = new JwtSecurityToken(header, payload);
                return new JwtSecurityTokenHandler().WriteToken(token);
            }
        }

        public string GenerateLoginUrl(string redirectUri)
        {
            if (string.IsNullOrWhiteSpace(redirectUri))
                throw new ArgumentException("Redirect URI is required.", nameof(redirectUri));

            var jwt = GenerateJwt();

            var isConsumer = _scope == ConsumerScope;
            var host = isConsumer ? ConsumerWorkspace : _workspace;
            var path = isConsumer ? ConsumerLoginPath : UserLoginPath;

            var effectiveRedirectUri = isConsumer
                ? redirectUri
                : GetUserRedirectUri(redirectUri);

            var clientAppName = GetClientAppName(redirectUri);

            var builder = new UriBuilder
            {
                Scheme = GetProtocol(),
                Host = host + GetTopLevelDomain(),
                Path = path
            };

            var query = HttpUtility.ParseQueryString(string.Empty);
            query["jwt"] = jwt;
            query["redirect_uri"] = effectiveRedirectUri;
            query["client_app_name"] = clientAppName;

            builder.Query = query.ToString();

            return builder.ToString();
        }

        private string GetProtocol()
        {
            return _environment == "development" ? "http" : "https";
        }

        private string GetTopLevelDomain()
        {
            switch (_environment)
            {
                case "staging":
                    return ".neetoauth.net";
                case "development":
                    return ".lvh.me:9000";
                case "production":
                default:
                    return ".neetoauth.com";
            }
        }

        private static string GetClientAppName(string redirectUri)
        {
            if (redirectUri == null)
                return "Cal";

            var lower = redirectUri.ToLowerInvariant();

            if (lower.Contains("neetodesk"))
                return "Desk";

            if (lower.Contains("neetocal"))
                return "Cal";

            if (lower.Contains("neetoform"))
                return "Form";

            if (lower.Contains("neetochat"))
                return "Chat";

            if (lower.Contains("neetokb"))
                return "KB";

            // This matches the JS client's fallback.
            return "Cal";
        }

        private string GetUserRedirectUri(string redirectUri)
        {
            // JS behavior:
            // "https://spinkart.neetodesk.com/admin"
            // becomes:
            // "neetodesk.com/admin"
            //
            // The JS repo strips the leading protocol, optional www, and first subdomain
            // for user-scope redirects.
            if (Uri.TryCreate(redirectUri, UriKind.Absolute, out var uri))
            {
                var hostParts = uri.Host.Split('.');

                if (hostParts.Length >= 3)
                {
                    var hostWithoutWorkspace = string.Join(".", hostParts.Skip(1));
                    var pathAndQuery = uri.PathAndQuery;

                    if (pathAndQuery == "/")
                        pathAndQuery = string.Empty;

                    return hostWithoutWorkspace + pathAndQuery;
                }
            }

            return GetTopLevelDomain();
        }

        private static ECDsa LoadEcdsaPrivateKey(string privateKeyPem)
        {
            if (string.IsNullOrWhiteSpace(privateKeyPem))
                throw new ArgumentException("Private key is required.", nameof(privateKeyPem));

            // If the key came from web.config as "\n", turn those into real newlines.
            privateKeyPem = privateKeyPem.Replace("\\n", "\n");

            using (var stringReader = new StringReader(privateKeyPem))
            {
                var pemReader = new PemReader(stringReader);
                var pemObject = pemReader.ReadObject();

                ECPrivateKeyParameters privateKeyParameters = null;

                if (pemObject is AsymmetricCipherKeyPair keyPair)
                {
                    privateKeyParameters = keyPair.Private as ECPrivateKeyParameters;
                }
                else if (pemObject is ECPrivateKeyParameters ecPrivateKeyParameters)
                {
                    privateKeyParameters = ecPrivateKeyParameters;
                }
                else if (pemObject is AsymmetricKeyParameter asymmetricKeyParameter)
                {
                    privateKeyParameters = asymmetricKeyParameter as ECPrivateKeyParameters;
                }

                if (privateKeyParameters == null)
                    throw new InvalidOperationException("The supplied key is not an EC private key.");

                // Convert BouncyCastle EC private key to PKCS#8 DER.
                var privateKeyInfo = PrivateKeyInfoFactory.CreatePrivateKeyInfo(privateKeyParameters);
                var privateKeyDer = privateKeyInfo.GetDerEncoded();

                // Import PKCS#8 EC private key into Windows CNG.
                var cngKey = CngKey.Import(privateKeyDer, CngKeyBlobFormat.Pkcs8PrivateBlob);

                return new ECDsaCng(cngKey)
                {
                    HashAlgorithm = CngAlgorithm.Sha256
                };
            }
        }
    }
}

Clone this wiki locally