| | | 1 | | using System.IdentityModel.Tokens.Jwt; |
| | | 2 | | using System.Security.Claims; |
| | | 3 | | using System.Security.Cryptography; |
| | | 4 | | using System.Security.Cryptography.X509Certificates; |
| | | 5 | | using Microsoft.IdentityModel.Tokens; |
| | | 6 | | using Serilog; |
| | | 7 | | using Serilog.Events; |
| | | 8 | | using System.Text; |
| | | 9 | | using System.Security; |
| | | 10 | | using System.Runtime.InteropServices; |
| | | 11 | | using Kestrun.Models; // For Base64UrlEncoder |
| | | 12 | | |
| | | 13 | | namespace Kestrun.Jwt; |
| | | 14 | | |
| | | 15 | | /// <summary> |
| | | 16 | | /// Fluent utility to create any flavour of JWS/JWE in one line. |
| | | 17 | | /// </summary> |
| | | 18 | | /// <example> |
| | | 19 | | /// // PowerShell usage: |
| | | 20 | | /// $builder = [Kestrun.Security.JwtTokenBuilder]::New() |
| | | 21 | | /// $token = $builder |
| | | 22 | | /// .WithSubject('admin') |
| | | 23 | | /// .WithIssuer('https://issuer') |
| | | 24 | | /// .WithAudience('api') |
| | | 25 | | /// .SignWithSecret('uZ6zDP3CGK3rktmVOXQk8A') # base64url |
| | | 26 | | /// .EncryptWithCertificate($cert,'RSA-OAEP','A256GCM') |
| | | 27 | | /// .Build() |
| | | 28 | | /// Write-Output $token |
| | | 29 | | /// </example> |
| | | 30 | | public sealed class JwtTokenBuilder |
| | | 31 | | { |
| | | 32 | | // ───── Public fluent API ────────────────────────────────────────── |
| | | 33 | | /// <summary> |
| | | 34 | | /// Creates a new instance of <see cref="JwtTokenBuilder"/>. |
| | | 35 | | /// </summary> |
| | | 36 | | /// <returns>A new <see cref="JwtTokenBuilder"/> instance.</returns> |
| | 29 | 37 | | public static JwtTokenBuilder New() => new(); |
| | | 38 | | |
| | | 39 | | /// <summary> |
| | | 40 | | /// Sets the issuer of the JWT token. |
| | | 41 | | /// </summary> |
| | | 42 | | /// <param name="issuer">The issuer to set.</param> |
| | | 43 | | /// <returns>The current <see cref="JwtTokenBuilder"/> instance.</returns> |
| | 52 | 44 | | public JwtTokenBuilder WithIssuer(string issuer) { _issuer = issuer; return this; } |
| | | 45 | | /// <summary> |
| | | 46 | | /// Sets the audience of the JWT token. |
| | | 47 | | /// </summary> |
| | | 48 | | /// <param name="audience">The audience to set.</param> |
| | | 49 | | /// <returns>The current <see cref="JwtTokenBuilder"/> instance.</returns> |
| | 52 | 50 | | public JwtTokenBuilder WithAudience(string audience) { _aud = audience; return this; } |
| | | 51 | | /// <summary> |
| | | 52 | | /// Sets the subject ('sub' claim) of the JWT token. |
| | | 53 | | /// </summary> |
| | | 54 | | /// <param name="sub">The subject to set.</param> |
| | | 55 | | /// <returns>The current <see cref="JwtTokenBuilder"/> instance.</returns> |
| | 50 | 56 | | public JwtTokenBuilder WithSubject(string sub) { _claims.Add(new Claim(Microsoft.IdentityModel.JsonWebTokens.JwtRegi |
| | | 57 | | /// <summary> |
| | | 58 | | /// Adds a claim to the JWT token. |
| | | 59 | | /// </summary> |
| | | 60 | | /// <param name="type">The claim type.</param> |
| | | 61 | | /// <param name="value">The claim value.</param> |
| | | 62 | | /// <returns>The current <see cref="JwtTokenBuilder"/> instance.</returns> |
| | 12 | 63 | | public JwtTokenBuilder AddClaim(string type, string value) { _claims.Add(new Claim(type, value)); return this; } |
| | | 64 | | /// <summary> |
| | | 65 | | /// Sets the lifetime (validity period) of the JWT token. |
| | | 66 | | /// </summary> |
| | | 67 | | /// <param name="ttl">The time span for which the token is valid.</param> |
| | | 68 | | /// <returns>The current <see cref="JwtTokenBuilder"/> instance.</returns> |
| | 14 | 69 | | public JwtTokenBuilder ValidFor(TimeSpan ttl) { _lifetime = ttl; return this; } |
| | | 70 | | /// <summary> |
| | | 71 | | /// Sets the 'not before' (nbf) claim for the JWT token. |
| | | 72 | | /// </summary> |
| | | 73 | | /// <param name="utc">The UTC date and time before which the token is not valid.</param> |
| | | 74 | | /// <returns>The current <see cref="JwtTokenBuilder"/> instance.</returns> |
| | 2 | 75 | | public JwtTokenBuilder NotBefore(DateTime utc) { _nbf = utc; return this; } |
| | | 76 | | /// <summary> |
| | | 77 | | /// Adds a custom header to the JWT token. |
| | | 78 | | /// </summary> |
| | | 79 | | /// <param name="k">The header key.</param> |
| | | 80 | | /// <param name="v">The header value.</param> |
| | | 81 | | /// <returns>The current <see cref="JwtTokenBuilder"/> instance.</returns> |
| | 8 | 82 | | public JwtTokenBuilder AddHeader(string k, object v) { _header[k] = v; return this; } |
| | | 83 | | |
| | | 84 | | /// <summary> |
| | | 85 | | /// Gets the issuer of the JWT token. |
| | | 86 | | /// </summary> |
| | 20 | 87 | | public string Issuer => _issuer ?? string.Empty; |
| | | 88 | | /// <summary> |
| | | 89 | | /// Gets the audience of the JWT token. |
| | | 90 | | /// </summary> |
| | 20 | 91 | | public string Audience => _aud ?? string.Empty; |
| | | 92 | | /// <summary> |
| | | 93 | | /// Gets the algorithm used for signing the JWT token. |
| | | 94 | | /// </summary> |
| | 53 | 95 | | public string? Algorithm { get; private set; } |
| | | 96 | | |
| | | 97 | | // ── pending-config “envelopes” (built later) ───────── |
| | 56 | 98 | | private sealed record PendingSymmetricSign(string B64u, string Alg /*auto/HS256…*/); |
| | 9 | 99 | | private sealed record PendingRsaSign(string Pem, string Alg); |
| | 15 | 100 | | private sealed record PendingCertSign(X509Certificate2 Cert, string Alg); |
| | 0 | 101 | | private sealed record PendingJwkSign(JsonWebKey Jwk, string Alg); |
| | | 102 | | |
| | 12 | 103 | | private sealed record PendingSymmetricEnc(string B64u, string KeyAlg, string EncAlg); |
| | 4 | 104 | | private sealed record PendingRsaEnc(string Pem, string KeyAlg, string EncAlg); |
| | 0 | 105 | | private sealed record PendingCertEnc(X509Certificate2 Cert, string KeyAlg, string EncAlg); |
| | 0 | 106 | | private sealed record PendingJwkEnc(string JwkJson, string KeyAlg, string EncAlg); |
| | | 107 | | |
| | | 108 | | private object? _pendingSign; // will be one of the above |
| | | 109 | | private object? _pendingEnc; |
| | | 110 | | private SymmetricSecurityKey? _issuerSigningKey; |
| | | 111 | | |
| | | 112 | | // ── signing helpers (store only) ───────────────────── |
| | | 113 | | /// <summary> |
| | | 114 | | /// Signs the JWT using a symmetric key provided as a Base64Url-encoded string. |
| | | 115 | | /// </summary> |
| | | 116 | | /// <param name="b64Url">The symmetric key as a Base64Url-encoded string.</param> |
| | | 117 | | /// <param name="alg">The JWT algorithm to use for signing (default is Auto).</param> |
| | | 118 | | /// <returns>The current <see cref="JwtTokenBuilder"/> instance.</returns> |
| | | 119 | | public JwtTokenBuilder SignWithSecret( |
| | | 120 | | string b64Url, |
| | | 121 | | JwtAlgorithm alg = JwtAlgorithm.Auto) |
| | | 122 | | { |
| | 20 | 123 | | if (string.IsNullOrWhiteSpace(b64Url)) |
| | | 124 | | { |
| | 2 | 125 | | throw new ArgumentNullException(nameof(b64Url)); |
| | | 126 | | } |
| | | 127 | | |
| | | 128 | | // 1) Decode the incoming Base64Url to bytes |
| | 18 | 129 | | var raw = Base64UrlEncoder.DecodeBytes(b64Url); |
| | | 130 | | |
| | | 131 | | // 2) Create (and remember) the SymmetricSecurityKey |
| | 18 | 132 | | var key = new SymmetricSecurityKey(raw) |
| | 18 | 133 | | { |
| | 18 | 134 | | KeyId = Guid.NewGuid().ToString("N") |
| | 18 | 135 | | }; |
| | 18 | 136 | | _issuerSigningKey = key; |
| | | 137 | | |
| | | 138 | | // 3) Resolve "Auto" or map the enum to the exact JWS alg string |
| | 18 | 139 | | var resolvedAlg = alg.ToJwtString(raw.Length); |
| | | 140 | | |
| | | 141 | | // 4) Store the pending sign using the resolved algorithm |
| | 18 | 142 | | _pendingSign = new PendingSymmetricSign(b64Url, resolvedAlg); |
| | | 143 | | |
| | 18 | 144 | | return this; |
| | | 145 | | } |
| | | 146 | | |
| | | 147 | | |
| | | 148 | | /// <summary> |
| | | 149 | | /// Creates a new token builder instance by cloning the current configuration. |
| | | 150 | | /// </summary> |
| | | 151 | | /// <returns>A new <see cref="JwtTokenBuilder"/> instance with the same configuration.</returns> |
| | | 152 | | public JwtTokenBuilder CloneBuilder() |
| | | 153 | | { |
| | 2 | 154 | | var clone = (JwtTokenBuilder)MemberwiseClone(); |
| | 2 | 155 | | clone._claims = [.. _claims]; |
| | 2 | 156 | | return clone; |
| | | 157 | | } |
| | | 158 | | |
| | | 159 | | /// <summary> |
| | | 160 | | /// Signs the JWT using a symmetric key provided as a hexadecimal string. |
| | | 161 | | /// </summary> |
| | | 162 | | /// <param name="hex">The symmetric key as a hexadecimal string.</param> |
| | | 163 | | /// <param name="alg">The JWT algorithm to use for signing (default is Auto).</param> |
| | | 164 | | /// <returns>The current <see cref="JwtTokenBuilder"/> instance.</returns> |
| | 2 | 165 | | public JwtTokenBuilder SignWithSecretHex(string hex, JwtAlgorithm alg = JwtAlgorithm.Auto) => SignWithSecret(Base64U |
| | | 166 | | |
| | | 167 | | /// <summary> |
| | | 168 | | /// Signs the JWT using a symmetric key derived from the provided passphrase. |
| | | 169 | | /// </summary> |
| | | 170 | | /// <param name="passPhrase">The passphrase to use as the symmetric key.</param> |
| | | 171 | | /// <param name="alg">The JWT algorithm to use for signing (default is Auto).</param> |
| | | 172 | | /// <returns>The current <see cref="JwtTokenBuilder"/> instance.</returns> |
| | | 173 | | public JwtTokenBuilder SignWithSecretPassphrase( |
| | | 174 | | SecureString passPhrase, |
| | | 175 | | JwtAlgorithm alg = JwtAlgorithm.Auto) |
| | | 176 | | { |
| | 1 | 177 | | ArgumentNullException.ThrowIfNull(passPhrase); |
| | | 178 | | |
| | | 179 | | // Marshal to unmanaged Unicode (UTF-16) buffer |
| | 1 | 180 | | var unicodePtr = Marshal.SecureStringToGlobalAllocUnicode(passPhrase); |
| | | 181 | | try |
| | | 182 | | { |
| | 1 | 183 | | var charCount = passPhrase.Length; |
| | 1 | 184 | | var unicodeBytes = new byte[charCount * sizeof(char)]; |
| | | 185 | | // copy from unmanaged -> managed |
| | 1 | 186 | | Marshal.Copy(unicodePtr, unicodeBytes, 0, unicodeBytes.Length); |
| | | 187 | | |
| | | 188 | | // convert UTF-16 bytes directly to UTF-8 |
| | 1 | 189 | | var utf8Bytes = Encoding.Convert(Encoding.Unicode, Encoding.UTF8, unicodeBytes); |
| | | 190 | | // zero-out the intermediate Unicode bytes |
| | 1 | 191 | | Array.Clear(unicodeBytes, 0, unicodeBytes.Length); |
| | | 192 | | |
| | 1 | 193 | | var b64url = Base64UrlEncoder.Encode(utf8Bytes); |
| | | 194 | | // zero-out the UTF-8 bytes too |
| | 1 | 195 | | Array.Clear(utf8Bytes, 0, utf8Bytes.Length); |
| | | 196 | | |
| | 1 | 197 | | return SignWithSecret(b64url, alg); |
| | | 198 | | } |
| | | 199 | | finally |
| | | 200 | | { |
| | | 201 | | // zero-free the unmanaged buffer |
| | 1 | 202 | | Marshal.ZeroFreeGlobalAllocUnicode(unicodePtr); |
| | 1 | 203 | | } |
| | 1 | 204 | | } |
| | | 205 | | |
| | | 206 | | // ── inside JwtTokenBuilder ───────────────────────────────────────── |
| | | 207 | | |
| | | 208 | | /// <summary> |
| | | 209 | | /// Signs the JWT using an RSA private key provided in PEM format. |
| | | 210 | | /// </summary> |
| | | 211 | | /// <param name="pemPath">The file path to the RSA private key in PEM format.</param> |
| | | 212 | | /// <param name="alg">The JWT algorithm to use for signing (default is Auto).</param> |
| | | 213 | | /// <returns>The current <see cref="JwtTokenBuilder"/> instance.</returns> |
| | | 214 | | public JwtTokenBuilder SignWithRsaPem( |
| | | 215 | | string pemPath, |
| | | 216 | | JwtAlgorithm alg = JwtAlgorithm.Auto) |
| | | 217 | | { |
| | 3 | 218 | | var pem = File.ReadAllText(pemPath); |
| | | 219 | | |
| | | 220 | | // Auto ⇒ default RS256; otherwise map enum to the exact string |
| | 3 | 221 | | var resolvedAlg = alg == JwtAlgorithm.Auto |
| | 3 | 222 | | ? SecurityAlgorithms.RsaSha256 |
| | 3 | 223 | | : alg.ToJwtString(0); |
| | | 224 | | |
| | 3 | 225 | | _pendingSign = new PendingRsaSign(pem, resolvedAlg); |
| | 3 | 226 | | return this; |
| | | 227 | | } |
| | | 228 | | |
| | | 229 | | /// <summary> |
| | | 230 | | /// Sign with an X.509 certificate (must have private key). |
| | | 231 | | /// </summary> |
| | | 232 | | public JwtTokenBuilder SignWithCertificate( |
| | | 233 | | X509Certificate2 cert, |
| | | 234 | | JwtAlgorithm alg = JwtAlgorithm.Auto) |
| | | 235 | | { |
| | 6 | 236 | | if (!cert.HasPrivateKey) |
| | | 237 | | { |
| | 1 | 238 | | throw new ArgumentException( |
| | 1 | 239 | | "Certificate must contain a private key.", nameof(cert)); |
| | | 240 | | } |
| | | 241 | | |
| | | 242 | | // Auto ⇒ ES256 for ECDSA keys, RS256 for RSA keys |
| | 5 | 243 | | var resolvedAlg = alg == JwtAlgorithm.Auto |
| | 5 | 244 | | ? (cert.GetECDsaPublicKey() is not null |
| | 5 | 245 | | ? SecurityAlgorithms.EcdsaSha256 |
| | 5 | 246 | | : SecurityAlgorithms.RsaSha256) |
| | 5 | 247 | | : alg.ToJwtString(0); |
| | | 248 | | |
| | 5 | 249 | | _pendingSign = new PendingCertSign(cert, resolvedAlg); |
| | 5 | 250 | | return this; |
| | | 251 | | } |
| | | 252 | | |
| | | 253 | | /// <summary> |
| | | 254 | | /// Signs the JWT using a JWK JSON string (RSA / EC / oct). |
| | | 255 | | /// </summary> |
| | | 256 | | /// <param name="jwkJson">The JWK JSON string.</param> |
| | | 257 | | /// <param name="alg"> |
| | | 258 | | /// The algorithm to use. If <see cref="JwtAlgorithm.Auto"/>, a default is chosen |
| | | 259 | | /// based on JWK key type (RS256 for RSA, ES256 for EC, HS256 for symmetric). |
| | | 260 | | /// </param> |
| | | 261 | | public JwtTokenBuilder SignWithJwkJson( |
| | | 262 | | string jwkJson, |
| | | 263 | | JwtAlgorithm alg = JwtAlgorithm.Auto) |
| | | 264 | | { |
| | 0 | 265 | | if (string.IsNullOrWhiteSpace(jwkJson)) |
| | | 266 | | { |
| | 0 | 267 | | throw new ArgumentNullException(nameof(jwkJson)); |
| | | 268 | | } |
| | | 269 | | |
| | 0 | 270 | | var jwk = new JsonWebKey(jwkJson); |
| | | 271 | | |
| | | 272 | | // Determine algorithm |
| | | 273 | | string resolvedAlg; |
| | 0 | 274 | | resolvedAlg = (alg == JwtAlgorithm.Auto) ? |
| | 0 | 275 | | jwk.Kty switch |
| | 0 | 276 | | { |
| | 0 | 277 | | "RSA" => SecurityAlgorithms.RsaSha256, |
| | 0 | 278 | | "EC" => SecurityAlgorithms.EcdsaSha256, |
| | 0 | 279 | | "oct" => SecurityAlgorithms.HmacSha256, |
| | 0 | 280 | | _ => throw new NotSupportedException( |
| | 0 | 281 | | $"Unsupported JWK key type '{jwk.Kty}'.") |
| | 0 | 282 | | } |
| | 0 | 283 | | : alg.ToJwtString(0); // You already map JwtAlgorithm → SecurityAlgorithms via ToJwtString |
| | | 284 | | |
| | | 285 | | |
| | | 286 | | |
| | | 287 | | // For symmetric JWKs, we can treat it like other HMAC keys for validation helpers |
| | 0 | 288 | | if (string.Equals(jwk.Kty, "oct", StringComparison.OrdinalIgnoreCase)) |
| | | 289 | | { |
| | | 290 | | // JWK 'k' is the base64url-encoded raw key material |
| | 0 | 291 | | if (string.IsNullOrEmpty(jwk.K)) |
| | | 292 | | { |
| | 0 | 293 | | throw new InvalidOperationException("Symmetric JWK is missing 'k' value."); |
| | | 294 | | } |
| | | 295 | | |
| | 0 | 296 | | var raw = Base64UrlEncoder.DecodeBytes(jwk.K); |
| | 0 | 297 | | _issuerSigningKey = new SymmetricSecurityKey(raw) |
| | 0 | 298 | | { |
| | 0 | 299 | | KeyId = jwk.Kid ?? Guid.NewGuid().ToString("N") |
| | 0 | 300 | | }; |
| | | 301 | | } |
| | | 302 | | |
| | 0 | 303 | | _pendingSign = new PendingJwkSign(jwk, resolvedAlg); |
| | 0 | 304 | | return this; |
| | | 305 | | } |
| | | 306 | | |
| | | 307 | | |
| | | 308 | | // ── encryption helpers (lazy) ─────────────────────────────────── |
| | | 309 | | |
| | | 310 | | // 1️⃣ X.509 certificate (RSA or EC public key) |
| | | 311 | | /// <summary> |
| | | 312 | | /// Encrypts the JWT using the provided X.509 certificate. |
| | | 313 | | /// </summary> |
| | | 314 | | /// <param name="cert">The X.509 certificate to use for encryption.</param> |
| | | 315 | | /// <param name="keyAlg">The key encryption algorithm (default is "RSA-OAEP").</param> |
| | | 316 | | /// <param name="encAlg">The content encryption algorithm (default is "A256GCM").</param> |
| | | 317 | | /// <returns>The current <see cref="JwtTokenBuilder"/> instance.</returns> |
| | | 318 | | public JwtTokenBuilder EncryptWithCertificate( |
| | | 319 | | X509Certificate2 cert, |
| | | 320 | | string keyAlg = "RSA-OAEP", |
| | | 321 | | string encAlg = "A256GCM") |
| | | 322 | | { |
| | 0 | 323 | | _pendingEnc = new PendingCertEnc(cert, keyAlg, encAlg); |
| | 0 | 324 | | return this; |
| | | 325 | | } |
| | | 326 | | |
| | | 327 | | /// <summary> |
| | | 328 | | /// Encrypts the JWT using a PEM-encoded RSA public key. |
| | | 329 | | /// </summary> |
| | | 330 | | /// <param name="pemPath">The file path to the PEM-encoded RSA public key.</param> |
| | | 331 | | /// <param name="keyAlg">The key encryption algorithm (default is "RSA-OAEP").</param> |
| | | 332 | | /// <param name="encAlg">The content encryption algorithm (default is "A256GCM").</param> |
| | | 333 | | /// <returns>The current <see cref="JwtTokenBuilder"/> instance.</returns> |
| | | 334 | | public JwtTokenBuilder EncryptWithPemPublic( |
| | | 335 | | string pemPath, |
| | | 336 | | string keyAlg = "RSA-OAEP", |
| | | 337 | | string encAlg = "A256GCM") |
| | | 338 | | { |
| | 1 | 339 | | _pendingEnc = new PendingRsaEnc(File.ReadAllText(pemPath), keyAlg, encAlg); |
| | 1 | 340 | | return this; |
| | | 341 | | } |
| | | 342 | | |
| | | 343 | | /// <summary> |
| | | 344 | | /// Encrypts the JWT using a symmetric key provided as a hexadecimal string. |
| | | 345 | | /// </summary> |
| | | 346 | | /// <param name="hex">The symmetric key as a hexadecimal string.</param> |
| | | 347 | | /// <param name="keyAlg">The key encryption algorithm (default is "dir").</param> |
| | | 348 | | /// <param name="encAlg">The content encryption algorithm (default is "A256CBC-HS512").</param> |
| | | 349 | | /// <returns>The current <see cref="JwtTokenBuilder"/> instance.</returns> |
| | | 350 | | public JwtTokenBuilder EncryptWithSecretHex( |
| | | 351 | | string hex, |
| | | 352 | | string keyAlg = "dir", |
| | 0 | 353 | | string encAlg = "A256CBC-HS512") => EncryptWithSecret(Convert.FromHexString(hex), keyAlg, encAlg); |
| | | 354 | | |
| | | 355 | | /// <summary> |
| | | 356 | | /// Encrypts the JWT using a symmetric key provided as a Base64Url-encoded string. |
| | | 357 | | /// </summary> |
| | | 358 | | /// <param name="b64Url">The symmetric key as a Base64Url-encoded string.</param> |
| | | 359 | | /// <param name="keyAlg">The key encryption algorithm (default is "dir").</param> |
| | | 360 | | /// <param name="encAlg">The content encryption algorithm (default is "A256CBC-HS512").</param> |
| | | 361 | | /// <returns>The current <see cref="JwtTokenBuilder"/> instance.</returns> |
| | | 362 | | public JwtTokenBuilder EncryptWithSecretB64( |
| | | 363 | | string b64Url, |
| | | 364 | | string keyAlg = "dir", |
| | 0 | 365 | | string encAlg = "A256CBC-HS512") => EncryptWithSecret(Base64UrlEncoder.DecodeBytes(b64Url), keyAlg, encAlg); |
| | | 366 | | |
| | | 367 | | /// <summary> |
| | | 368 | | /// Encrypts the JWT using a symmetric key provided as a byte array. |
| | | 369 | | /// </summary> |
| | | 370 | | /// <param name="keyBytes">The symmetric key as a byte array.</param> |
| | | 371 | | /// <param name="keyAlg">The key encryption algorithm (default is "dir").</param> |
| | | 372 | | /// <param name="encAlg">The content encryption algorithm (default is "A256CBC-HS512").</param> |
| | | 373 | | /// <returns>The current <see cref="JwtTokenBuilder"/> instance.</returns> |
| | | 374 | | public JwtTokenBuilder EncryptWithSecret( |
| | | 375 | | byte[] keyBytes, |
| | | 376 | | string keyAlg = "dir", |
| | | 377 | | string encAlg = "A256CBC-HS512") |
| | | 378 | | { |
| | 3 | 379 | | var b64u = Base64UrlEncoder.Encode(keyBytes); |
| | 3 | 380 | | _pendingEnc = new PendingSymmetricEnc(b64u, keyAlg, encAlg); |
| | 3 | 381 | | return this; |
| | | 382 | | } |
| | | 383 | | |
| | | 384 | | |
| | | 385 | | /// <summary> |
| | | 386 | | /// Encrypts the JWT payload using a JWK (public key) in JSON format. |
| | | 387 | | /// </summary> |
| | | 388 | | /// <param name="jwkJson">The JWK JSON string (typically an RSA or EC public key).</param> |
| | | 389 | | /// <param name="keyAlg">The JWE key management algorithm (default: "RSA-OAEP").</param> |
| | | 390 | | /// <param name="encAlg">The JWE content encryption algorithm (default: "A256GCM").</param> |
| | | 391 | | /// <returns>The current <see cref="JwtTokenBuilder"/> instance.</returns> |
| | | 392 | | public JwtTokenBuilder EncryptWithJwkJson( |
| | | 393 | | string jwkJson, |
| | | 394 | | string keyAlg = "RSA-OAEP", |
| | | 395 | | string encAlg = "A256GCM") |
| | | 396 | | { |
| | 0 | 397 | | if (string.IsNullOrWhiteSpace(jwkJson)) |
| | | 398 | | { |
| | 0 | 399 | | throw new ArgumentException("JWK JSON cannot be null or empty.", nameof(jwkJson)); |
| | | 400 | | } |
| | | 401 | | |
| | 0 | 402 | | _pendingEnc = new PendingJwkEnc(jwkJson, keyAlg, encAlg); |
| | 0 | 403 | | return this; |
| | | 404 | | } |
| | | 405 | | |
| | | 406 | | /// <summary> |
| | | 407 | | /// Encrypts the JWT payload using a JWK read from a file. |
| | | 408 | | /// </summary> |
| | | 409 | | /// <param name="jwkPath">Path to the JWK JSON file.</param> |
| | | 410 | | /// <param name="keyAlg">The JWE key management algorithm (default: "RSA-OAEP").</param> |
| | | 411 | | /// <param name="encAlg">The JWE content encryption algorithm (default: "A256GCM").</param> |
| | | 412 | | /// <returns>The current <see cref="JwtTokenBuilder"/> instance.</returns> |
| | | 413 | | public JwtTokenBuilder EncryptWithJwkPath( |
| | | 414 | | string jwkPath, |
| | | 415 | | string keyAlg = "RSA-OAEP", |
| | | 416 | | string encAlg = "A256GCM") |
| | | 417 | | { |
| | 0 | 418 | | if (string.IsNullOrWhiteSpace(jwkPath)) |
| | | 419 | | { |
| | 0 | 420 | | throw new ArgumentException("JWK path cannot be null or empty.", nameof(jwkPath)); |
| | | 421 | | } |
| | | 422 | | |
| | 0 | 423 | | var fullPath = Path.GetFullPath(jwkPath); |
| | 0 | 424 | | var jwkJson = File.ReadAllText(fullPath); |
| | | 425 | | |
| | 0 | 426 | | _pendingEnc = new PendingJwkEnc(jwkJson, keyAlg, encAlg); |
| | 0 | 427 | | return this; |
| | | 428 | | } |
| | | 429 | | |
| | | 430 | | |
| | | 431 | | |
| | | 432 | | // ───── Build the compact JWT ────────────────────────────────────── |
| | | 433 | | |
| | | 434 | | /// <summary> |
| | | 435 | | /// Builds the JWT token. |
| | | 436 | | /// This method constructs the JWT token using the configured parameters and returns it as a compact string. |
| | | 437 | | /// </summary> |
| | | 438 | | /// <returns>The JWT token as a compact string.</returns> |
| | | 439 | | /// <exception cref="InvalidOperationException">Thrown if no signing credentials are configured.</exception> |
| | | 440 | | /// <remarks> |
| | | 441 | | /// This method constructs the JWT token using the configured parameters and returns it as a compact string. |
| | | 442 | | /// </remarks> |
| | | 443 | | private string BuildToken() |
| | | 444 | | { |
| | 25 | 445 | | var handler = new JwtSecurityTokenHandler(); |
| | | 446 | | // ── build creds lazily now ─────────────────────────── |
| | 25 | 447 | | var signCreds = BuildSigningCredentials(out _issuerSigningKey) ?? throw new InvalidOperationException("No signin |
| | 25 | 448 | | Algorithm = signCreds.Algorithm; |
| | 25 | 449 | | var encCreds = BuildEncryptingCredentials(); |
| | 24 | 450 | | if (_nbf < DateTime.UtcNow) |
| | | 451 | | { |
| | 24 | 452 | | _nbf = DateTime.UtcNow; |
| | | 453 | | } |
| | 24 | 454 | | var token = handler.CreateJwtSecurityToken( |
| | 24 | 455 | | issuer: _issuer, |
| | 24 | 456 | | audience: _aud, |
| | 24 | 457 | | subject: new ClaimsIdentity(_claims), |
| | 24 | 458 | | notBefore: _nbf, |
| | 24 | 459 | | expires: _nbf.Add(_lifetime), |
| | 24 | 460 | | issuedAt: DateTime.UtcNow, |
| | 24 | 461 | | signingCredentials: signCreds, |
| | 24 | 462 | | encryptingCredentials: encCreds); |
| | | 463 | | |
| | | 464 | | |
| | 54 | 465 | | foreach (var kv in _header) |
| | | 466 | | { |
| | 4 | 467 | | token.Header[kv.Key] = kv.Value; |
| | | 468 | | } |
| | | 469 | | |
| | 23 | 470 | | return handler.WriteToken(token); |
| | | 471 | | } |
| | | 472 | | |
| | | 473 | | /// <summary> |
| | | 474 | | /// Builds the JWT token. |
| | | 475 | | /// This method constructs the JWT token using the configured parameters and returns it as a compact string. |
| | | 476 | | /// </summary> |
| | | 477 | | /// <param name="signingKey">The signing key used to sign the JWT.</param> |
| | | 478 | | /// <returns>The JWT token as a compact string.</returns> |
| | | 479 | | /// <exception cref="InvalidOperationException">Thrown if no signing credentials are configured.</exception> |
| | | 480 | | /// <remarks> |
| | | 481 | | /// This method constructs the JWT token using the configured parameters and returns it as a compact string. |
| | | 482 | | /// </remarks> |
| | | 483 | | private string BuildToken(out SymmetricSecurityKey? signingKey) |
| | | 484 | | { |
| | 25 | 485 | | var jwt = BuildToken(); // call the original Build() |
| | 23 | 486 | | signingKey = _issuerSigningKey; // may be null for unsigned / RSA / cert |
| | 23 | 487 | | return jwt; |
| | | 488 | | } |
| | | 489 | | |
| | | 490 | | |
| | | 491 | | /// <summary> |
| | | 492 | | /// Builds the JWT token and returns a <see cref="JwtBuilderResult"/> containing the token, signing key, and validit |
| | | 493 | | /// </summary> |
| | | 494 | | /// <returns>A <see cref="JwtBuilderResult"/> containing the JWT token, signing key, and validity period.</returns> |
| | | 495 | | public JwtBuilderResult Build() |
| | | 496 | | { |
| | | 497 | | // ① produce the raw token + signing key |
| | 25 | 498 | | var token = BuildToken(out var key); |
| | | 499 | | // ② Parse it immediately to pull out the valid-from / valid-to |
| | 23 | 500 | | var handler = new JwtSecurityTokenHandler(); |
| | 23 | 501 | | var jwtToken = handler.ReadJwtToken(token); |
| | 23 | 502 | | var issuedAt = jwtToken.ValidFrom.ToUniversalTime(); |
| | 23 | 503 | | var expires = jwtToken.ValidTo.ToUniversalTime(); |
| | | 504 | | // ③ return the helper object |
| | 23 | 505 | | return new JwtBuilderResult(token, key, this, issuedAt, expires); |
| | | 506 | | } |
| | | 507 | | |
| | | 508 | | // ────────── helpers that materialise creds ─────────── |
| | | 509 | | |
| | | 510 | | /// <summary> |
| | | 511 | | /// Builds the signing credentials. |
| | | 512 | | /// This method constructs the signing credentials based on the pending signing configuration. |
| | | 513 | | /// If no signing configuration is set, it returns null. |
| | | 514 | | /// </summary> |
| | | 515 | | /// <param name="key">The symmetric security key to use for signing.</param> |
| | | 516 | | /// <returns>The signing credentials, or null if not configured.</returns> |
| | | 517 | | /// <exception cref="InvalidOperationException">Thrown if no signing configuration is set.</exception> |
| | | 518 | | /// <remarks> |
| | | 519 | | /// This method constructs the signing credentials based on the pending signing configuration. |
| | | 520 | | /// If no signing configuration is set, it returns null. |
| | | 521 | | /// </remarks> |
| | | 522 | | private SigningCredentials? BuildSigningCredentials(out SymmetricSecurityKey? key) |
| | | 523 | | { |
| | 27 | 524 | | key = null; |
| | | 525 | | |
| | 27 | 526 | | return _pendingSign switch |
| | 27 | 527 | | { |
| | 19 | 528 | | PendingSymmetricSign ps => CreateHsCreds(ps, out key), |
| | 3 | 529 | | PendingRsaSign pr => CreateRsaCreds(pr), |
| | 5 | 530 | | PendingCertSign pc => CreateCertCreds(pc), |
| | 0 | 531 | | PendingJwkSign pj => CreateJwkCreds(pj), |
| | 0 | 532 | | _ => null |
| | 27 | 533 | | }; |
| | | 534 | | } |
| | | 535 | | |
| | | 536 | | /// <summary> |
| | | 537 | | /// Builds the signing credentials. |
| | | 538 | | /// This method constructs the signing credentials based on the pending signing configuration. |
| | | 539 | | /// If no signing configuration is set, it returns null. |
| | | 540 | | /// </summary> |
| | | 541 | | /// <param name="ps">The pending symmetric signing configuration.</param> |
| | | 542 | | /// <param name="key">The symmetric security key to use for signing.</param> |
| | | 543 | | /// <returns>The signing credentials, or null if not configured.</returns> |
| | | 544 | | /// <exception cref="InvalidOperationException">Thrown if no signing configuration is set.</exception> |
| | | 545 | | /// <remarks> |
| | | 546 | | /// This method constructs the signing credentials based on the pending symmetric signing configuration. |
| | | 547 | | /// If no signing configuration is set, it returns null. |
| | | 548 | | /// </remarks> |
| | | 549 | | private static SigningCredentials CreateHsCreds( |
| | | 550 | | PendingSymmetricSign ps, |
| | | 551 | | out SymmetricSecurityKey key) |
| | | 552 | | { |
| | | 553 | | // 1) decode the Base64Url secret |
| | 19 | 554 | | var raw = Base64UrlEncoder.DecodeBytes(ps.B64u); |
| | | 555 | | |
| | | 556 | | // 2) create the SymmetricSecurityKey (and record its KeyId) |
| | 19 | 557 | | key = new SymmetricSecurityKey(raw) |
| | 19 | 558 | | { |
| | 19 | 559 | | KeyId = Guid.NewGuid().ToString("N") |
| | 19 | 560 | | }; |
| | | 561 | | |
| | | 562 | | // 3) ps.Alg is *already* the exact SecurityAlgorithms.* constant |
| | 19 | 563 | | return new SigningCredentials(key, ps.Alg); |
| | | 564 | | } |
| | | 565 | | |
| | | 566 | | /// <summary> |
| | | 567 | | /// Creates signing credentials for RSA using the provided PEM string. |
| | | 568 | | /// This method imports the RSA key from the PEM string and returns the signing credentials. |
| | | 569 | | /// </summary> |
| | | 570 | | /// <param name="pr">The pending RSA signing configuration.</param> |
| | | 571 | | /// <returns>The signing credentials for RSA.</returns> |
| | | 572 | | /// <exception cref="InvalidOperationException">Thrown if the PEM string is invalid or cannot be imported.</exceptio |
| | | 573 | | /// <remarks> |
| | | 574 | | /// This method imports the RSA key from the PEM string and returns the signing credentials. |
| | | 575 | | /// </remarks> |
| | | 576 | | private static SigningCredentials CreateRsaCreds(PendingRsaSign pr) |
| | | 577 | | { |
| | 3 | 578 | | var rsa = RSA.Create(); |
| | 3 | 579 | | rsa.ImportFromPem(pr.Pem); |
| | 3 | 580 | | var key = new RsaSecurityKey(rsa) |
| | 3 | 581 | | { |
| | 3 | 582 | | KeyId = Guid.NewGuid().ToString("N") |
| | 3 | 583 | | }; |
| | 3 | 584 | | return new SigningCredentials(key, pr.Alg); |
| | | 585 | | } |
| | | 586 | | /// <summary> |
| | | 587 | | /// Creates signing credentials for a certificate. |
| | | 588 | | /// </summary> |
| | | 589 | | /// <param name="pc">The pending certificate signing configuration.</param> |
| | | 590 | | /// <returns>The signing credentials for the certificate.</returns> |
| | | 591 | | /// <exception cref="InvalidOperationException">Thrown if the certificate does not have a private key.</exception> |
| | | 592 | | /// <remarks> |
| | | 593 | | /// This method creates signing credentials for a certificate using the provided certificate. |
| | | 594 | | /// </remarks> |
| | | 595 | | private static SigningCredentials CreateCertCreds(PendingCertSign pc) |
| | | 596 | | { |
| | 5 | 597 | | var cert = pc.Cert; |
| | 5 | 598 | | var key = new X509SecurityKey(cert); // thumbprint becomes kid |
| | 5 | 599 | | return new SigningCredentials(key, pc.Alg); |
| | | 600 | | } |
| | | 601 | | |
| | | 602 | | /// <summary> |
| | | 603 | | /// Creates signing credentials for a JsonWebKey. |
| | | 604 | | /// </summary> |
| | | 605 | | /// <param name="pj">The pending JWK signing configuration.</param> |
| | | 606 | | /// <returns>The signing credentials for the JWK.</returns> |
| | | 607 | | /// <exception cref="NotSupportedException">Thrown if the JWK key type is incompatible with the specified algorithm. |
| | | 608 | | /// <remarks> |
| | | 609 | | /// This method creates signing credentials for a JsonWebKey using the provided JWK. |
| | | 610 | | /// </remarks> |
| | | 611 | | private static SigningCredentials CreateJwkCreds( |
| | | 612 | | PendingJwkSign pj) |
| | | 613 | | { |
| | 0 | 614 | | var jwk = pj.Jwk; |
| | | 615 | | |
| | | 616 | | // Optional sanity checks: ensure alg matches key type |
| | 0 | 617 | | if (string.Equals(jwk.Kty, "RSA", StringComparison.OrdinalIgnoreCase) && |
| | 0 | 618 | | !pj.Alg.StartsWith("RS", StringComparison.OrdinalIgnoreCase)) |
| | | 619 | | { |
| | 0 | 620 | | throw new NotSupportedException( |
| | 0 | 621 | | $"Incompatible algorithm '{pj.Alg}' for RSA JWK (kty='RSA')."); |
| | | 622 | | } |
| | | 623 | | |
| | 0 | 624 | | if (string.Equals(jwk.Kty, "EC", StringComparison.OrdinalIgnoreCase) && |
| | 0 | 625 | | !pj.Alg.StartsWith("ES", StringComparison.OrdinalIgnoreCase)) |
| | | 626 | | { |
| | 0 | 627 | | throw new NotSupportedException( |
| | 0 | 628 | | $"Incompatible algorithm '{pj.Alg}' for EC JWK (kty='EC')."); |
| | | 629 | | } |
| | | 630 | | |
| | 0 | 631 | | if (string.Equals(jwk.Kty, "oct", StringComparison.OrdinalIgnoreCase) && |
| | 0 | 632 | | !pj.Alg.StartsWith("HS", StringComparison.OrdinalIgnoreCase)) |
| | | 633 | | { |
| | 0 | 634 | | throw new NotSupportedException( |
| | 0 | 635 | | $"Incompatible algorithm '{pj.Alg}' for symmetric JWK (kty='oct')."); |
| | | 636 | | } |
| | | 637 | | |
| | | 638 | | // JsonWebKey is already a SecurityKey |
| | 0 | 639 | | return new SigningCredentials(jwk, pj.Alg); |
| | | 640 | | } |
| | | 641 | | |
| | | 642 | | |
| | | 643 | | /// <summary> |
| | | 644 | | /// Builds the encrypting credentials. |
| | | 645 | | /// This method constructs the encrypting credentials based on the pending encryption configuration. |
| | | 646 | | /// If no encryption configuration is set, it returns null. |
| | | 647 | | /// </summary> |
| | | 648 | | /// <returns>The encrypting credentials, or null if not set.</returns> |
| | | 649 | | /// <remarks> |
| | | 650 | | /// This method constructs the encrypting credentials based on the pending encryption configuration. |
| | | 651 | | /// If no encryption configuration is set, it returns null. |
| | | 652 | | /// </remarks> |
| | | 653 | | private EncryptingCredentials? BuildEncryptingCredentials() |
| | 27 | 654 | | => _pendingEnc switch |
| | 27 | 655 | | { |
| | 3 | 656 | | PendingSymmetricEnc se => new SymmetricEncrypt( |
| | 3 | 657 | | se.B64u, se.KeyAlg, se.EncAlg).ToEncryptingCreds(), |
| | 1 | 658 | | PendingRsaEnc re => new RsaEncrypt( |
| | 1 | 659 | | re.Pem, re.KeyAlg, re.EncAlg).ToEncryptingCreds(), |
| | 0 | 660 | | PendingCertEnc ce => new CertEncrypt( |
| | 0 | 661 | | ce.Cert, ce.KeyAlg, ce.EncAlg).ToEncryptingCreds(), |
| | 0 | 662 | | PendingJwkEnc je => new JwkEncrypt( |
| | 0 | 663 | | je.JwkJson, je.KeyAlg, je.EncAlg).ToEncryptingCreds(), |
| | 23 | 664 | | _ => null |
| | 27 | 665 | | }; |
| | | 666 | | |
| | | 667 | | |
| | | 668 | | |
| | | 669 | | // ───── Internals ────────────────────────────────────────────────── |
| | | 670 | | /// <summary> |
| | | 671 | | /// Gets the claims to be included in the JWT token. |
| | | 672 | | /// </summary> |
| | 29 | 673 | | private List<Claim> _claims = []; |
| | | 674 | | /// <summary> |
| | | 675 | | /// Gets the headers to be included in the JWT token. |
| | | 676 | | /// </summary> |
| | 29 | 677 | | private readonly Dictionary<string, object> _header = new(StringComparer.OrdinalIgnoreCase); |
| | | 678 | | /// <summary> |
| | | 679 | | /// Gets the not before (nbf) claim for the JWT token. |
| | | 680 | | /// </summary> |
| | 29 | 681 | | private DateTime _nbf = DateTime.UtcNow; |
| | | 682 | | /// <summary> |
| | | 683 | | /// Gets the lifetime of the JWT token. |
| | | 684 | | /// </summary> |
| | 29 | 685 | | private TimeSpan _lifetime = TimeSpan.FromHours(1); |
| | | 686 | | private string? _issuer, _aud; |
| | | 687 | | |
| | | 688 | | |
| | | 689 | | // ── Strategy interfaces & concrete configs ─────────────────────── |
| | | 690 | | private interface ISignConfig { SigningCredentials ToSigningCreds(); } |
| | | 691 | | private interface IEncConfig { EncryptingCredentials ToEncryptingCreds(); } |
| | | 692 | | |
| | | 693 | | private static class Map |
| | | 694 | | { |
| | | 695 | | // maps short names (HS256, RSA-OAEP, …) to SecurityAlgorithms |
| | 1 | 696 | | public static readonly IReadOnlyDictionary<string, string> Jws = new Dictionary<string, string>(StringComparer.O |
| | 1 | 697 | | { |
| | 1 | 698 | | ["HS256"] = SecurityAlgorithms.HmacSha256, |
| | 1 | 699 | | ["HS384"] = SecurityAlgorithms.HmacSha384, |
| | 1 | 700 | | ["HS512"] = SecurityAlgorithms.HmacSha512, |
| | 1 | 701 | | ["RS256"] = SecurityAlgorithms.RsaSha256, |
| | 1 | 702 | | ["RS384"] = SecurityAlgorithms.RsaSha384, |
| | 1 | 703 | | ["RS512"] = SecurityAlgorithms.RsaSha512, |
| | 1 | 704 | | ["PS256"] = SecurityAlgorithms.RsaSsaPssSha256, |
| | 1 | 705 | | ["PS384"] = SecurityAlgorithms.RsaSsaPssSha384, |
| | 1 | 706 | | ["PS512"] = SecurityAlgorithms.RsaSsaPssSha512, |
| | 1 | 707 | | ["ES256"] = SecurityAlgorithms.EcdsaSha256, |
| | 1 | 708 | | ["ES384"] = SecurityAlgorithms.EcdsaSha384, |
| | 1 | 709 | | ["ES512"] = SecurityAlgorithms.EcdsaSha512 |
| | 1 | 710 | | }; |
| | 1 | 711 | | public static readonly IReadOnlyDictionary<string, string> KeyAlg = new Dictionary<string, string>(StringCompare |
| | 1 | 712 | | { |
| | 1 | 713 | | ["RSA-OAEP"] = SecurityAlgorithms.RsaOAEP, |
| | 1 | 714 | | ["RSA-OAEP-256"] = "RSA-OAEP-256", |
| | 1 | 715 | | ["RSA-OAEP-384"] = "RSA-OAEP-384", |
| | 1 | 716 | | ["RSA-OAEP-512"] = "RSA-OAEP-512", |
| | 1 | 717 | | ["RSA1_5"] = SecurityAlgorithms.RsaPKCS1, |
| | 1 | 718 | | ["A128KW"] = SecurityAlgorithms.Aes128KW, |
| | 1 | 719 | | ["A192KW"] = SecurityAlgorithms.Aes192KW, |
| | 1 | 720 | | ["A256KW"] = SecurityAlgorithms.Aes256KW, |
| | 1 | 721 | | ["ECDH-ES"] = SecurityAlgorithms.EcdhEs, |
| | 1 | 722 | | ["ECDH-ESA128KW"] = SecurityAlgorithms.EcdhEsA128kw, |
| | 1 | 723 | | ["ECDH-ESA192KW"] = SecurityAlgorithms.EcdhEsA192kw, |
| | 1 | 724 | | ["ECDH-ESA256KW"] = SecurityAlgorithms.EcdhEsA256kw, |
| | 1 | 725 | | ["dir"] = "dir" |
| | 1 | 726 | | }; |
| | 1 | 727 | | public static readonly IReadOnlyDictionary<string, string> EncAlg = new Dictionary<string, string>(StringCompare |
| | 1 | 728 | | { |
| | 1 | 729 | | ["A128GCM"] = SecurityAlgorithms.Aes128Gcm, |
| | 1 | 730 | | ["A192GCM"] = SecurityAlgorithms.Aes192Gcm, |
| | 1 | 731 | | ["A256GCM"] = SecurityAlgorithms.Aes256Gcm, |
| | 1 | 732 | | ["A128CBC-HS256"] = SecurityAlgorithms.Aes128CbcHmacSha256, |
| | 1 | 733 | | ["A192CBC-HS384"] = SecurityAlgorithms.Aes192CbcHmacSha384, |
| | 1 | 734 | | ["A256CBC-HS512"] = SecurityAlgorithms.Aes256CbcHmacSha512 |
| | 1 | 735 | | }; |
| | | 736 | | } |
| | | 737 | | |
| | | 738 | | // ── Signing configs ─────────────────────────────────────────────── |
| | 0 | 739 | | private sealed record SymmetricSign( |
| | 0 | 740 | | SecurityKey Key, string ResolvedAlg) : ISignConfig |
| | | 741 | | { |
| | | 742 | | public SigningCredentials ToSigningCreds() |
| | 0 | 743 | | => new(Key, ResolvedAlg); |
| | | 744 | | } |
| | | 745 | | |
| | 0 | 746 | | private sealed record RsaSign(string Pem, string Alg) : ISignConfig |
| | | 747 | | { |
| | | 748 | | public SigningCredentials ToSigningCreds() |
| | | 749 | | { |
| | 0 | 750 | | var rsa = RSA.Create(); rsa.ImportFromPem(Pem); |
| | 0 | 751 | | var key = new RsaSecurityKey(rsa); |
| | 0 | 752 | | var algo = Alg.Equals("auto", StringComparison.OrdinalIgnoreCase) ? Map.Jws["RS256"] : Map.Jws[Alg]; |
| | 0 | 753 | | return new SigningCredentials(key, algo); |
| | | 754 | | } |
| | | 755 | | } |
| | | 756 | | |
| | | 757 | | |
| | 0 | 758 | | private sealed record CertSign(X509Certificate2 Cert, string Alg) : ISignConfig |
| | | 759 | | { |
| | | 760 | | public SigningCredentials ToSigningCreds() |
| | | 761 | | { |
| | 0 | 762 | | if (!Cert.HasPrivateKey) |
| | | 763 | | { |
| | 0 | 764 | | throw new ArgumentException("Certificate must contain a private key."); |
| | | 765 | | } |
| | | 766 | | |
| | 0 | 767 | | var key = new X509SecurityKey(Cert); |
| | | 768 | | |
| | | 769 | | // Pick default alg if caller passed "auto" |
| | | 770 | | string resolvedAlg; |
| | 0 | 771 | | if (!Alg.Equals("auto", StringComparison.OrdinalIgnoreCase)) |
| | | 772 | | { |
| | 0 | 773 | | resolvedAlg = Map.Jws[Alg]; |
| | | 774 | | } |
| | | 775 | | else |
| | | 776 | | { |
| | 0 | 777 | | if (Cert.GetECDsaPublicKey() is not null) |
| | | 778 | | { |
| | 0 | 779 | | resolvedAlg = Map.Jws["ES256"]; // ECDSA → ES256 by default |
| | | 780 | | } |
| | 0 | 781 | | else if (Cert.GetRSAPublicKey() is not null) |
| | | 782 | | { |
| | 0 | 783 | | resolvedAlg = Map.Jws["RS256"]; // RSA → RS256 by default |
| | | 784 | | } |
| | | 785 | | else |
| | | 786 | | { |
| | 0 | 787 | | var keyType = "unknown"; |
| | 0 | 788 | | if (Cert.PublicKey != null && Cert.PublicKey.EncodedKeyValue != null && Cert.PublicKey.EncodedKeyVal |
| | | 789 | | { |
| | 0 | 790 | | keyType = Cert.PublicKey.EncodedKeyValue.Oid.FriendlyName ?? "unknown"; |
| | | 791 | | } |
| | | 792 | | |
| | 0 | 793 | | throw new NotSupportedException( |
| | 0 | 794 | | $"Unsupported key type: {keyType}"); |
| | | 795 | | } |
| | | 796 | | } |
| | | 797 | | |
| | 0 | 798 | | return new SigningCredentials(key, resolvedAlg); |
| | | 799 | | } |
| | | 800 | | } |
| | | 801 | | |
| | | 802 | | // ── Encryption configs ──────────────────────────────────────────── |
| | 27 | 803 | | private abstract record BaseEnc(string KeyAlg, string EncAlg) : IEncConfig |
| | | 804 | | { |
| | 1 | 805 | | protected string KeyAlgMapped => Map.KeyAlg[KeyAlg]; |
| | 1 | 806 | | protected string EncAlgMapped => Map.EncAlg[EncAlg]; |
| | | 807 | | public abstract EncryptingCredentials ToEncryptingCreds(); |
| | | 808 | | } |
| | | 809 | | |
| | 0 | 810 | | private sealed record CertEncrypt(X509Certificate2 Cert, string KeyAlg, string EncAlg) : BaseEnc(KeyAlg, EncAlg) |
| | | 811 | | { |
| | | 812 | | public override EncryptingCredentials ToEncryptingCreds() |
| | | 813 | | { |
| | 0 | 814 | | var key = new X509SecurityKey(Cert); |
| | 0 | 815 | | return new EncryptingCredentials(key, KeyAlgMapped, EncAlgMapped); |
| | | 816 | | } |
| | | 817 | | } |
| | | 818 | | |
| | 2 | 819 | | private sealed record RsaEncrypt(string Pem, string KeyAlg, string EncAlg) : BaseEnc(KeyAlg, EncAlg) |
| | | 820 | | { |
| | | 821 | | public override EncryptingCredentials ToEncryptingCreds() |
| | | 822 | | { |
| | 2 | 823 | | var rsa = RSA.Create(); rsa.ImportFromPem(Pem); |
| | 1 | 824 | | var key = new RsaSecurityKey(rsa); |
| | 1 | 825 | | return new EncryptingCredentials(key, KeyAlgMapped, EncAlgMapped); |
| | | 826 | | } |
| | | 827 | | } |
| | | 828 | | |
| | 0 | 829 | | private sealed record JwkEncrypt(string JwkJson, string KeyAlg, string EncAlg) : BaseEnc(KeyAlg, EncAlg) |
| | | 830 | | { |
| | | 831 | | public override EncryptingCredentials ToEncryptingCreds() |
| | | 832 | | { |
| | | 833 | | // JsonWebKey is a SecurityKey |
| | 0 | 834 | | var jwk = new JsonWebKey(JwkJson); |
| | 0 | 835 | | var key = (SecurityKey)jwk; |
| | | 836 | | |
| | 0 | 837 | | return new EncryptingCredentials( |
| | 0 | 838 | | key, |
| | 0 | 839 | | KeyAlgMapped, // mapped from Map.KeyAlg |
| | 0 | 840 | | EncAlgMapped // mapped from Map.EncAlg |
| | 0 | 841 | | ); |
| | | 842 | | } |
| | | 843 | | } |
| | | 844 | | |
| | | 845 | | private sealed record SymmetricEncrypt( |
| | 6 | 846 | | string B64, |
| | | 847 | | string KeyAlg, |
| | 3 | 848 | | string EncAlg) : BaseEnc(KeyAlg, EncAlg) |
| | | 849 | | { |
| | | 850 | | public override EncryptingCredentials ToEncryptingCreds() |
| | | 851 | | { |
| | 3 | 852 | | if (Log.IsEnabled(LogEventLevel.Debug)) |
| | | 853 | | { |
| | 3 | 854 | | Log.Debug( |
| | 3 | 855 | | "Encrypting with {KeyAlg} and {EncAlg} ({Bits} bits)", |
| | 3 | 856 | | KeyAlg, EncAlg, Base64UrlEncoder.DecodeBytes(B64).Length * 8); |
| | | 857 | | } |
| | | 858 | | // ────────── shared-secret → SymmetricSecurityKey ────────── |
| | 3 | 859 | | if (!Map.KeyAlg.ContainsKey(KeyAlg)) |
| | | 860 | | { |
| | 0 | 861 | | throw new ArgumentException($"Unknown key algorithm: {KeyAlg}"); |
| | | 862 | | } |
| | | 863 | | |
| | 3 | 864 | | var key = new SymmetricSecurityKey(Base64UrlEncoder.DecodeBytes(B64)); |
| | 3 | 865 | | var bits = key.KeySize; // 128 / 192 / 256 / 384 / 512 … |
| | | 866 | | |
| | | 867 | | // ────────── auto-pick encAlg for 'dir' default case ─────── |
| | 3 | 868 | | var encEff = EncAlg; |
| | | 869 | | |
| | 3 | 870 | | if (KeyAlg.Equals("dir", StringComparison.OrdinalIgnoreCase) && |
| | 3 | 871 | | EncAlg.Equals("A256CBC-HS512", StringComparison.OrdinalIgnoreCase)) |
| | | 872 | | { |
| | 0 | 873 | | encEff = bits switch |
| | 0 | 874 | | { |
| | 0 | 875 | | 128 => "A128GCM", |
| | 0 | 876 | | 192 => "A192GCM", |
| | 0 | 877 | | 256 => "A256GCM", |
| | 0 | 878 | | 384 => "A192CBC-HS384", |
| | 0 | 879 | | 512 => "A256CBC-HS512", |
| | 0 | 880 | | _ => throw new ArgumentException( |
| | 0 | 881 | | $"Unsupported key size {bits} bits for direct encryption.") |
| | 0 | 882 | | }; |
| | | 883 | | } |
| | | 884 | | |
| | | 885 | | // ────────── hard validation (caller may specify any enc) ── |
| | | 886 | | static void Require(int actualBits, int requiredBits, string alg) |
| | | 887 | | { |
| | 3 | 888 | | _ = actualBits == requiredBits |
| | 3 | 889 | | ? true |
| | 3 | 890 | | : throw new ArgumentException($"{alg} requires a {requiredBits}-bit key."); |
| | 2 | 891 | | } |
| | | 892 | | |
| | 3 | 893 | | switch (encEff.ToUpperInvariant()) |
| | | 894 | | { |
| | 2 | 895 | | case "A128GCM": Require(bits, 128, encEff); break; |
| | 0 | 896 | | case "A192GCM": Require(bits, 192, encEff); break; |
| | 1 | 897 | | case "A256GCM": Require(bits, 256, encEff); break; |
| | 2 | 898 | | case "A128CBC-HS256": Require(bits, 256, encEff); break; |
| | 0 | 899 | | case "A192CBC-HS384": Require(bits, 384, encEff); break; |
| | 0 | 900 | | case "A256CBC-HS512": Require(bits, 512, encEff); break; |
| | | 901 | | default: |
| | 0 | 902 | | throw new ArgumentException($"Unknown or unsupported enc algorithm: {encEff}"); |
| | | 903 | | } |
| | 2 | 904 | | if (Log.IsEnabled(LogEventLevel.Debug)) |
| | | 905 | | { |
| | 2 | 906 | | Log.Debug( |
| | 2 | 907 | | "Encrypting with {KeyAlg} and {EncAlg} ({Bits} bits)", |
| | 2 | 908 | | KeyAlg, encEff, bits); |
| | | 909 | | } |
| | | 910 | | // ────────── build EncryptingCredentials ─────────────────── |
| | 2 | 911 | | return new EncryptingCredentials( |
| | 2 | 912 | | key, |
| | 2 | 913 | | Map.KeyAlg[KeyAlg.ToUpperInvariant()], // 'dir', 'A256KW', … |
| | 2 | 914 | | Map.EncAlg[encEff.ToUpperInvariant()]); // validated / auto-picked enc |
| | | 915 | | } |
| | | 916 | | } |
| | | 917 | | |
| | | 918 | | /// <summary> |
| | | 919 | | /// Renews a JWT token from the current request context, optionally extending its lifetime. |
| | | 920 | | /// </summary> |
| | | 921 | | /// <param name="ctx">The Kestrun context containing the request and authorization header.</param> |
| | | 922 | | /// <param name="lifetime">The new lifetime for the renewed token. If null, uses the builder's default lifetime.</pa |
| | | 923 | | |
| | | 924 | | /// <returns>The renewed JWT token as a compact string.</returns> |
| | | 925 | | /// <exception cref="UnauthorizedAccessException">Thrown if no Bearer token is provided in the request.</exception> |
| | | 926 | | public string RenewJwt( |
| | | 927 | | KestrunContext ctx, |
| | | 928 | | TimeSpan? lifetime = null) |
| | | 929 | | { |
| | 2 | 930 | | if (ctx.Request.Authorization == null || (!ctx.Request.Authorization?.StartsWith("Bearer ") ?? true)) |
| | | 931 | | { |
| | 1 | 932 | | return string.Empty; |
| | | 933 | | } |
| | 1 | 934 | | var authHeader = ctx.Request.Authorization; |
| | 1 | 935 | | var strToken = authHeader != null ? authHeader["Bearer ".Length..].Trim() : throw new UnauthorizedAccessExceptio |
| | 1 | 936 | | return RenewJwt(jwt: strToken, lifetime: lifetime); |
| | | 937 | | } |
| | | 938 | | |
| | | 939 | | /// <summary> |
| | | 940 | | /// Extends the validity period of an existing JWT token by creating a new token with updated lifetime. |
| | | 941 | | /// </summary> |
| | | 942 | | /// <param name="jwt">The original JWT token to extend.</param> |
| | | 943 | | /// <param name="lifetime">The new lifetime for the extended token. If null, uses the builder's default lifetime.</p |
| | | 944 | | /// <returns>The extended JWT token as a compact string.</returns> |
| | | 945 | | public string RenewJwt( |
| | | 946 | | string jwt, |
| | | 947 | | TimeSpan? lifetime = null) |
| | | 948 | | { |
| | 2 | 949 | | var handler = new JwtSecurityTokenHandler(); |
| | | 950 | | |
| | | 951 | | // Read raw token (no mapping, no validation) |
| | 2 | 952 | | var old = handler.ReadJwtToken(jwt); |
| | 2 | 953 | | var _builder = CloneBuilder(); |
| | | 954 | | // Copy all non-time claims |
| | 2 | 955 | | var reserved = new HashSet<string>(StringComparer.OrdinalIgnoreCase) |
| | 2 | 956 | | { "exp", "nbf", "iat" }; |
| | | 957 | | |
| | 15 | 958 | | var claims = old.Claims.Where(c => !reserved.Contains(c.Type)).ToList(); |
| | | 959 | | |
| | | 960 | | // If you rely on "sub", make sure it’s there (some libs put it into NameIdentifier) |
| | 4 | 961 | | if (!claims.Any(c => c.Type == JwtRegisteredClaimNames.Sub)) |
| | | 962 | | { |
| | 0 | 963 | | var sub = old.Claims.FirstOrDefault(c => |
| | 0 | 964 | | c.Type is JwtRegisteredClaimNames.Sub or |
| | 0 | 965 | | ClaimTypes.NameIdentifier)?.Value; |
| | 0 | 966 | | if (!string.IsNullOrEmpty(sub)) |
| | | 967 | | { |
| | 0 | 968 | | claims.Add(new Claim(JwtRegisteredClaimNames.Sub, sub)); |
| | | 969 | | } |
| | | 970 | | } |
| | | 971 | | |
| | 2 | 972 | | var signCreds = BuildSigningCredentials(out _issuerSigningKey) ?? throw new InvalidOperationException("No signin |
| | 2 | 973 | | Algorithm = signCreds.Algorithm; |
| | 2 | 974 | | var encCreds = BuildEncryptingCredentials(); |
| | | 975 | | |
| | | 976 | | // Keep the same kid if present by setting it on the signing key |
| | | 977 | | // signing.Key.KeyId = old.Header.Kid; // uncomment if you must mirror the old 'kid' |
| | 2 | 978 | | if (_nbf < DateTime.UtcNow) |
| | | 979 | | { |
| | 2 | 980 | | _nbf = DateTime.UtcNow; |
| | | 981 | | } |
| | 2 | 982 | | if (lifetime is null) |
| | | 983 | | { |
| | 0 | 984 | | lifetime = _lifetime; |
| | | 985 | | } |
| | 2 | 986 | | else if (lifetime < TimeSpan.Zero) |
| | | 987 | | { |
| | 0 | 988 | | throw new ArgumentOutOfRangeException(nameof(lifetime), "Lifetime must be a positive TimeSpan."); |
| | | 989 | | } |
| | 2 | 990 | | var token = handler.CreateJwtSecurityToken( |
| | 2 | 991 | | issuer: _issuer, |
| | 2 | 992 | | audience: _aud, |
| | 2 | 993 | | subject: new ClaimsIdentity(claims), |
| | 2 | 994 | | notBefore: _nbf, |
| | 2 | 995 | | expires: _nbf.Add((TimeSpan)lifetime), |
| | 2 | 996 | | issuedAt: DateTime.UtcNow, |
| | 2 | 997 | | signingCredentials: signCreds, |
| | 2 | 998 | | encryptingCredentials: encCreds); |
| | | 999 | | |
| | 4 | 1000 | | foreach (var kv in _header) |
| | | 1001 | | { |
| | 0 | 1002 | | token.Header[kv.Key] = kv.Value; |
| | | 1003 | | } |
| | | 1004 | | |
| | 2 | 1005 | | return handler.WriteToken(token); |
| | | 1006 | | } |
| | | 1007 | | /* |
| | | 1008 | | public JwtTokenPackage BuildPackage() |
| | | 1009 | | { |
| | | 1010 | | string jwt = BuildToken(out var key); // your existing overload |
| | | 1011 | | |
| | | 1012 | | var tvp = new TokenValidationParameters |
| | | 1013 | | { |
| | | 1014 | | ValidateIssuer = true, |
| | | 1015 | | ValidIssuer = _issuer, // private fields in builder |
| | | 1016 | | ValidateAudience = true, |
| | | 1017 | | ValidAudience = _aud, |
| | | 1018 | | ValidateLifetime = true, |
| | | 1019 | | ClockSkew = TimeSpan.FromMinutes(1), |
| | | 1020 | | |
| | | 1021 | | RequireSignedTokens = key is not null, |
| | | 1022 | | ValidateIssuerSigningKey = key is not null, |
| | | 1023 | | IssuerSigningKey = key, |
| | | 1024 | | ValidAlgorithms = key is not null |
| | | 1025 | | ? new[] { SecurityAlgorithms.HmacSha256 } |
| | | 1026 | | : Array.Empty<string>() |
| | | 1027 | | }; |
| | | 1028 | | |
| | | 1029 | | return new JwtTokenPackage(jwt, key, tvp); |
| | | 1030 | | } |
| | | 1031 | | */ |
| | | 1032 | | } |