Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 25 additions & 3 deletions Semantics.Strings.Identifiers/IsJwtTokenAttribute.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,18 @@
namespace ktsu.Semantics.Strings.Identifiers;

using System;
using System.Linq;
using System.Text;

using ktsu.Semantics.Strings;

/// <summary>
/// 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. <c>alg=none</c>) and is neither decoded nor verified.
/// object. Every segment must use only the unpadded base64url alphabet (<c>A-Z</c>, <c>a-z</c>,
/// <c>0-9</c>, <c>-</c>, <c>_</c>), so whitespace, <c>=</c> padding, <c>+</c> and <c>/</c> are
/// rejected (RFC 7515 搂2). The signature segment may be empty (e.g. <c>alg=none</c>) and is neither
/// decoded nor verified.
/// <para>
/// 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 <c>alg</c>) is inspected. Use a JWT
Expand Down Expand Up @@ -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.");
}

/// <summary>
/// Whether every character is in the unpadded base64url alphabet. Checked before decoding
/// because <see cref="Convert.FromBase64String(string)"/> skips whitespace and accepts
/// <c>+</c>, <c>/</c> and <c>=</c>, none of which a JWT segment may contain.
/// </summary>
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)
{
Expand Down
2 changes: 1 addition & 1 deletion Semantics.Strings.Identifiers/JwtToken.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ namespace ktsu.Semantics.Strings.Identifiers;
using ktsu.Semantics.Strings;

/// <summary>
/// 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 <b>signature is not verified</b>, and <c>alg</c>,
/// claims, and expiry are not inspected.
Expand Down
2 changes: 1 addition & 1 deletion Semantics.Strings.Identifiers/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,7 @@ Each type is a `sealed record` deriving from `SemanticString<T>`. 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.

Expand Down
37 changes: 37 additions & 0 deletions Semantics.Test/Identifiers/JwtTokenTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,43 @@ public void Create_HeaderNotBase64Url_Throws()
Assert.ThrowsExactly<ArgumentException>(() => 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<ArgumentException>(() => 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<ArgumentException>(() => 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<ArgumentException>(() => 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()
{
Expand Down
Loading