-
Notifications
You must be signed in to change notification settings - Fork 1
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
};
}
}
}
}