DEV Community

Mofajjal Rasul
Mofajjal Rasul

Posted on

JWT with OIDC Authentication in Distributed Systems: Building Trust at Scale

When you’re wrangling a distributed system, authentication can feel like herding cats while riding a unicycle. You need a solution where services can validate tokens without cozying up to share secrets like they’re swapping gossip at a high school reunion. Enter our hero: a JWT-based architecture using asymmetric cryptography and OpenID Connect (OIDC) discovery. It’s secure, it’s scalable, and it’s got just enough swagger to make lesser auth systems jealous.

The Architecture: Trust Centrally, Validate Wildly

The idea is deceptively simple: one service plays the role of token-signing overlord, while the rest of your services verify tokens like independent detectives, no secret-sharing required. This setup laughs in the face of single points of failure and scales like a tech startup’s dreams before the Series A funding runs dry.

Here’s the trifecta of components that make this magic happen:

  1. Token Service: The grand maestro that signs JWTs with a private key, keeping it locked away like a dragon hoarding gold.
  2. Authentication Middleware: The gatekeeper that verifies tokens using public keys, because who has time to hardcode secrets?
  3. OIDC Discovery Endpoints: The cool kids’ way of sharing public keys, enabling seamless key rotation without breaking a sweat.

1. Token Generation Service: Signing Tokens with a Smirk

This service is the VIP of our setup, wielding RSA asymmetric cryptography to sign JWTs with flair. The KeyId is the unsung hero here, ensuring key rotation doesn’t turn into a chaotic game of musical chairs. Also being registered as singleton service ensures no duplicate RsaSecurityKey generation while keeping CreateToken thread-safe too, win-win for nerds!

public class AsymmetricTokenProvider
{
    private readonly RsaSecurityKey _signatureKey; // The gatekeeper with the secret handshake
    private readonly JwtSecurityTokenHandler _tokenHandler = new(); 
    private readonly TokenConfiguration _config; 
    private readonly JsonWebKey _publicJwk; // Cached upfront so we don’t keep reinventing the wheel

    public AsymmetricTokenProvider(TokenConfiguration config)
    {
        _config = config;

        var rsa = RSA.Create();
        rsa.ImportFromPem(config.PrivateKey); // Load the private key — handle with care

        _signatureKey = new RsaSecurityKey(rsa)
        {
            KeyId = GenerateKeyFingerprint(rsa) // Every key deserves a cool nickname
        };

        _publicJwk = GeneratePublicJwk(_signatureKey); // Do it once, keep it forever (singleton life)
    }

    public string CreateToken(IEnumerable<Claim> assertions)
    {
        var descriptor = new SecurityTokenDescriptor
        {
            Issuer = _config.Issuer,   
            Audience = _config.Audience,
            Subject = new ClaimsIdentity(assertions),
            Expires = DateTime.UtcNow.Add(_config.DefaultDuration),
            SigningCredentials = new SigningCredentials(_signatureKey, SecurityAlgorithms.RsaSha256)
        };

        var token = _tokenHandler.CreateToken(descriptor);
        return _tokenHandler.WriteToken(token); // Out comes a shiny new JWT
    }

    public JsonWebKey GetPublicJwk() => _publicJwk; // Nothing to hide, just the public bits

    private static string GenerateKeyFingerprint(RSA rsa)
    {
        // Hash the modulus and truncate it: fingerprints for cryptographic celebrities
        var parameters = rsa.ExportParameters(false);
        using var hasher = SHA256.Create();
        var hash = hasher.ComputeHash(parameters.Modulus);
        return Base64UrlEncoder.Encode(hash.AsSpan(0, 16));
    }

    private static JsonWebKey GeneratePublicJwk(RsaSecurityKey signatureKey)
    {
        // Convert the public half of the key into a JSON Web Key for the outside world
        var publicParams = signatureKey.Rsa.ExportParameters(false);
        var publicKey = RSA.Create();
        publicKey.ImportParameters(publicParams);

        var jwk = JsonWebKeyConverter.ConvertFromRSASecurityKey(
            new RsaSecurityKey(publicKey) { KeyId = signatureKey.KeyId });

        jwk.Alg = SecurityAlgorithms.RsaSha256;
        jwk.Use = "sig"; // For signatures only — no encryption shenanigans
        return jwk;
    }
}
Enter fullscreen mode Exit fullscreen mode

2. Authentication Middleware: The Bouncer with Trust Issues

The middleware is your system’s skeptical bouncer, checking tokens without needing to phone home to the token service. It dynamically fetches public keys from the authority server, because hardcoding keys is so 2010.

services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
    .AddJwtBearer(options =>
    {
        options.Authority = configuration["Jwt:AuthorityUrl"]; // Basically this server which shares OIDC, LMAO
        options.RequireHttpsMetadata = false; // Flip to true in prod, unless you like living dangerously

        options.TokenValidationParameters = new TokenValidationParameters
        {
            ValidateSignature = true, // No fake IDs allowed
            ValidateIssuer = true,
            ValidIssuer = configuration["Jwt:IssuingAuthority"],
            ValidateAudience = true,
            ValidAudience = configuration["Jwt:Audience"],
            ValidateLifetime = true,
            ClockSkew = TimeSpan.FromMinutes(5) // Because clocks are never *that* synced
        };
    });
Enter fullscreen mode Exit fullscreen mode

3. OIDC Discovery Endpoints: The Key-Sharing Party

These endpoints are the social butterflies of the system, serving up public keys via standardized OIDC routes. They make key rotation as smooth as a sunny afternoon, no manual updates required.

[ApiController]
[Route(".well-known")]
[AllowAnonymous]
public class DiscoveryEndpoints : ControllerBase
{
    private readonly AsymmetricTokenProvider _tokenProvider;
    private readonly LinkGenerator _urlGenerator;

    public DiscoveryEndpoints(AsymmetricTokenProvider tokenProvider, LinkGenerator urlGenerator)
    {
        _tokenProvider = tokenProvider;
        _urlGenerator = urlGenerator; // Helping us navigate the URL jungle
    }

    [HttpGet("openid-configuration")]
    public IActionResult GetOpenIdConfiguration()
    {
        var keysEndpoint = _urlGenerator.GetUriByAction(
            HttpContext, 
            nameof(GetJsonWebKeys), 
            controller: "DiscoveryEndpoints");

        return Ok(new
        {
            issuer = "https://api.example.com",
            jwks_uri = keysEndpoint // "Here’s where the public keys party at"
        });
    }

    [HttpGet("jwks")]
    public IActionResult GetJsonWebKeys()
    {
        var jwk = _tokenProvider.GetPublicJwk();
        return Ok(new { keys = new[] { jwk } }); // Public keys, free to a good home
    }
}
Enter fullscreen mode Exit fullscreen mode

Why This Setup is the Bee’s Knees

  1. Decoupled Validation: Services validate tokens solo, no chit-chat with the auth service. Less latency, fewer tears.
  2. Automated Key Management: OIDC endpoints serve fresh public keys like a barista slinging lattes, making key rotation a breeze.
  3. Fort Knox Security: Private key stays locked up tight, while public keys are handed out like candy at a parade.
  4. Plays Nice with Standards: OIDC compatibility means it works with any client that speaks the universal auth language.
  5. Scales Like a Dream: Each service verifies independently, so you can scale to the moon without breaking a sweat.

This architecture is your ticket to a secure, scalable, and maintainable auth system. It’s built on cryptographic best practices and OIDC standards, ready to handle everything from your garage startup to a global empire. So go forth, authenticate with confidence, and maybe throw in a smug grin for good measure.

Top comments (0)