diff --git a/Semantics.Strings.Identifiers/IsJwtTokenAttribute.cs b/Semantics.Strings.Identifiers/IsJwtTokenAttribute.cs index 7aaa1b4a..e6bb222d 100644 --- a/Semantics.Strings.Identifiers/IsJwtTokenAttribute.cs +++ b/Semantics.Strings.Identifiers/IsJwtTokenAttribute.cs @@ -3,6 +3,7 @@ namespace ktsu.Semantics.Strings.Identifiers; using System; +using System.Linq; using System.Text; using ktsu.Semantics.Strings; @@ -10,7 +11,10 @@ namespace ktsu.Semantics.Strings.Identifiers; /// /// Validates that the string is a structurally well-formed JWT: exactly three '.'-separated segments, /// with non-empty header and payload segments that base64url-decode to UTF-8 text delimited as a JSON -/// object. The signature segment may be empty (e.g. alg=none) and is neither decoded nor verified. +/// object. Every segment must use only the unpadded base64url alphabet (A-Z, a-z, +/// 0-9, -, _), so whitespace, = padding, + and / are +/// rejected (RFC 7515 ยง2). The signature segment may be empty (e.g. alg=none) and is neither +/// decoded nor verified. /// /// The check is deliberately structural. The header and payload bodies are not parsed, so malformed /// JSON between the braces is accepted, and no claim (not even alg) is inspected. Use a JWT @@ -44,13 +48,31 @@ protected override ValidationResult ValidateValue(string value) return ValidationResult.Failure("The JWT header must be base64url-encoded JSON object."); } - return DecodesToJsonObject(parts[1]) + if (!DecodesToJsonObject(parts[1])) + { + return ValidationResult.Failure("The JWT payload must be base64url-encoded JSON object."); + } + + return IsBase64UrlAlphabet(parts[2]) ? ValidationResult.Success() - : ValidationResult.Failure("The JWT payload must be base64url-encoded JSON object."); + : ValidationResult.Failure("The JWT signature must use only base64url characters."); } + /// + /// Whether every character is in the unpadded base64url alphabet. Checked before decoding + /// because skips whitespace and accepts + /// +, / and =, none of which a JWT segment may contain. + /// + private static bool IsBase64UrlAlphabet(string segment) => + segment.All(c => c is (>= 'A' and <= 'Z') or (>= 'a' and <= 'z') or (>= '0' and <= '9') or '-' or '_'); + private static bool DecodesToJsonObject(string segment) { + if (!IsBase64UrlAlphabet(segment)) + { + return false; + } + string base64 = segment.Replace('-', '+').Replace('_', '/'); switch (base64.Length % 4) { diff --git a/Semantics.Strings.Identifiers/JwtToken.cs b/Semantics.Strings.Identifiers/JwtToken.cs index 2624a6dc..da11da1b 100644 --- a/Semantics.Strings.Identifiers/JwtToken.cs +++ b/Semantics.Strings.Identifiers/JwtToken.cs @@ -5,7 +5,7 @@ namespace ktsu.Semantics.Strings.Identifiers; using ktsu.Semantics.Strings; /// -/// A JSON Web Token in compact serialization: three '.'-separated base64url segments. The value is +/// A JSON Web Token in compact serialization: three '.'-separated unpadded base64url segments. The value is /// stored verbatim (no normalization) because it is opaque and case-sensitive. Validation confirms the /// header and payload decode to JSON objects; the signature is not verified, and alg, /// claims, and expiry are not inspected. diff --git a/Semantics.Strings.Identifiers/README.md b/Semantics.Strings.Identifiers/README.md index 4e70ea77..7c4800d8 100644 --- a/Semantics.Strings.Identifiers/README.md +++ b/Semantics.Strings.Identifiers/README.md @@ -89,7 +89,7 @@ Each type is a `sealed record` deriving from `SemanticString`. None declare t | `Iban` | Strip spaces, uppercase | Length 15-34, country/check prefix, ISO 7064 mod-97-10 checksum | Country-specific BBAN structure is not enforced. | | `Isbn` | Strip `-` and spaces, uppercase | ISBN-10 (weighted mod-11, `X` allowed) or ISBN-13 (mod-10) | Registration-group/publisher ranges are not validated. | | `CreditCardNumber` | Strip spaces and hyphens | 13-19 digits, Luhn (mod-10) checksum | Luhn only. No issuer/network detection, no PCI guarantee. The value is sensitive, do not log it. | -| `JwtToken` | None (stored verbatim, case-sensitive) | Three `.`-separated segments, non-empty header and payload that base64url-decode to UTF-8 text delimited as `{ ... }` | Structural only: the header and payload bodies are not parsed, so malformed JSON between the braces is accepted. Signature is not decoded or verified. `alg`, claims, and expiry are not inspected. | +| `JwtToken` | None (stored verbatim, case-sensitive) | Three `.`-separated segments in the unpadded base64url alphabet (no whitespace, `=`, `+` or `/`), non-empty header and payload that decode to UTF-8 text delimited as `{ ... }` | Structural only: the header and payload bodies are not parsed, so malformed JSON between the braces is accepted. Signature is not decoded or verified. `alg`, claims, and expiry are not inspected. | Creation follows the base-type contract: `Create(...)` throws `ArgumentException` on invalid input and `ArgumentNullException` on null, while `TryCreate(...)` returns `false` instead. diff --git a/Semantics.Test/Identifiers/JwtTokenTests.cs b/Semantics.Test/Identifiers/JwtTokenTests.cs index f39e333a..171dc516 100644 --- a/Semantics.Test/Identifiers/JwtTokenTests.cs +++ b/Semantics.Test/Identifiers/JwtTokenTests.cs @@ -54,6 +54,43 @@ public void Create_HeaderNotBase64Url_Throws() Assert.ThrowsExactly(() => JwtToken.Create("not*base64.eyJzdWIiOiIxMjM0NTY3ODkwIn0.sig")); } + [TestMethod] + [DataRow("eyJh bGci OiJI UzI1 NiJ9.eyJzdWIiOiIxIn0.sig")] + [DataRow("eyJhbGciOiJIUzI1NiJ9.eyJz dWIi OiIx In0.sig")] + [DataRow("eyJhbGciOiJIUzI1NiJ9\n.eyJzdWIiOiIxIn0.sig")] + public void Create_SegmentWithWhitespace_Throws(string token) + { + Assert.ThrowsExactly(() => JwtToken.Create(token)); + } + + [TestMethod] + [DataRow("e30=.e30=.x")] + [DataRow("e30.e30=.x")] + [DataRow("eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxIn0.sig=")] + public void Create_SegmentWithPadding_Throws(string token) + { + Assert.ThrowsExactly(() => JwtToken.Create(token)); + } + + [TestMethod] + [DataRow("eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxIn0+.sig")] + [DataRow("eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxIn0/.sig")] + [DataRow("eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxIn0.si+g")] + [DataRow("eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxIn0.si/g")] + [DataRow("eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxIn0.si g")] + public void Create_SegmentWithStandardBase64OrOtherCharacters_Throws(string token) + { + Assert.ThrowsExactly(() => JwtToken.Create(token)); + } + + [TestMethod] + public void Create_UnpaddedBase64UrlSegments_Succeeds() + { + const string token = "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxIn0.sig"; + JwtToken jwt = JwtToken.Create(token); + Assert.AreEqual(token, jwt.WeakString); + } + [TestMethod] public void Create_Empty_Throws() {