diff --git a/Assets/Sui-Unity-SDK/Code/Sui.ZKLogin/JwtUtils.cs b/Assets/Sui-Unity-SDK/Code/Sui.ZKLogin/JwtUtils.cs new file mode 100644 index 0000000..cae7f59 --- /dev/null +++ b/Assets/Sui-Unity-SDK/Code/Sui.ZKLogin/JwtUtils.cs @@ -0,0 +1,186 @@ +using System; +using System.Collections.Generic; +using System.Text; +using UnityEngine; + +namespace Sui.ZKLogin +{ + + //public record Claim + //{ + // public string Value { get; init; } + // public int IndexMod4 { get; init; } + //} + + /// + /// Represents a claim with a base64URL encoded value and its position indicator + /// + [Serializable] + public class Claim + { + /// + /// The base64URL encoded value of the claim + /// + public string value; + + /// + /// The position indicator modulo 4, used for decoding + /// + public int indexMod4; + } + + public class JwtUtils + { + /// + /// The standard base64URL character set used for encoding/decoding + /// + private static readonly string Base64UrlCharacterSet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"; + + /// + /// Converts a single base64URL character to its 6-bit binary representation + /// + /// A single character from the base64URL character set + /// Array of 6 bits representing the character + /// Thrown when the input is not a valid base64URL character + private static int[] Base64UrlCharTo6Bits(string base64UrlChar) + { + if (base64UrlChar.Length != 1) + throw new ArgumentException("Invalid base64Url character: " + base64UrlChar); + + int index = Base64UrlCharacterSet.IndexOf(base64UrlChar); + if (index == -1) + throw new ArgumentException("Invalid base64Url character: " + base64UrlChar); + + // Convert the index to a 6-bit binary representation + string binaryString = Convert.ToString(index, 2).PadLeft(6, '0'); + int[] bits = new int[6]; + for (int i = 0; i < 6; i++) + bits[i] = binaryString[i] == '1' ? 1 : 0; + return bits; + } + + //private static int[] Base64UrlStringToBitVector(string base64UrlString) + //{ + // return base64UrlString.SelectMany(c => Base64UrlCharTo6Bits(c)).ToArray(); + //} + + /// + /// Converts a base64URL encoded string to a bit vector + /// + /// The base64URL encoded string + /// Array of bits representing the entire string + private static int[] Base64UrlStringToBitVector(string base64UrlString) + { + List bitVector = new List(); + for (int i = 0; i < base64UrlString.Length; i++) + { + string base64UrlChar = base64UrlString[i].ToString(); + int[] bits = Base64UrlCharTo6Bits(base64UrlChar); + bitVector.AddRange(bits); + } + return bitVector.ToArray(); + } + + /// + /// Decodes a base64URL encoded string starting from a specific position + /// + /// The base64URL encoded string + /// The starting position for decoding + /// The decoded UTF8 string + /// Thrown when the input is not properly formatted or positioned + private static string DecodeBase64URL(string s, int i) + { + if (s.Length < 2) + throw new ArgumentException($"Input (s = {s}) is not tightly packed because s.length < 2"); + + List bits = new List(Base64UrlStringToBitVector(s)); + + // Handle the first character offset + int firstCharOffset = i % 4; + switch (firstCharOffset) + { + case 1: + bits.RemoveRange(0, 2); + break; + case 2: + bits.RemoveRange(0, 4); + break; + case 3: + throw new ArgumentException($"Input (s = {s}) is not tightly packed because i%4 = 3 (i = {i})"); + } + + // Handle the last character offset + int lastCharOffset = (i + s.Length - 1) % 4; + switch (lastCharOffset) + { + case 2: + bits.RemoveRange(bits.Count - 2, 2); // Remove last 2 bits + break; + case 1: + bits.RemoveRange(bits.Count - 4, 4); // Remove last 4 bits + break; + case 0: + throw new ArgumentException($"Input (s = {s}) is not tightly packed because (i + s.length - 1)%4 = 0 (i = {i})"); + } + + if (bits.Count % 8 != 0) + throw new Exception("We should never reach here..."); + + // Convert bit groups of 8 to bytes + byte[] bytes = new byte[bits.Count / 8]; + for (int byteIndex = 0; byteIndex < bytes.Length; byteIndex++) + { + string bitChunk = string.Join("", bits.GetRange(byteIndex * 8, 8)); + bytes[byteIndex] = Convert.ToByte(bitChunk, 2); + } + + return Encoding.UTF8.GetString(bytes); + } + + /// + /// Verifies and extracts the key-value pair from a claim string + /// + /// The claim string to verify + /// A tuple containing the key and value from the claim + /// Thrown when the claim format is invalid + private static (string key, string value) VerifyExtendedClaim(string claim) + { + // Verify claim ends with either '}' or ',' + if (!(claim.EndsWith("}") || claim.EndsWith(","))) + throw new ArgumentException("Invalid claim"); + + // Parse the claim as JSON + string jsonStr = "{" + claim.Substring(0, claim.Length - 1) + "}"; + var json = JsonUtility.FromJson>(jsonStr); + + // Verify the claim contains exactly one key-value pair + if (json.Count != 1) + throw new ArgumentException("Invalid claim"); + + // Return the first (and only) key-value pair + foreach (var kvp in json) + return (kvp.Key, kvp.Value.ToString()); + + throw new ArgumentException("Invalid claim"); + } + + /// + /// Extracts and deserializes a claim value + /// + /// The type to deserialize the claim value to + /// The claim object containing the encoded value and position + /// The expected name of the claim + /// The deserialized claim value + /// Thrown when the claim name doesn't match or the claim is invalid + public static T ExtractClaimValue(Claim claim, string claimName) + { + string extendedClaim = DecodeBase64URL(claim.value, claim.indexMod4); + var (name, value) = VerifyExtendedClaim(extendedClaim); + + if (name != claimName) + throw new ArgumentException($"Invalid field name: found {name} expected {claimName}"); + + return JsonUtility.FromJson(value); + } + } +} \ No newline at end of file diff --git a/Assets/Sui-Unity-SDK/Code/Sui.ZKLogin/JwtUtils.cs.meta b/Assets/Sui-Unity-SDK/Code/Sui.ZKLogin/JwtUtils.cs.meta new file mode 100644 index 0000000..d97d799 --- /dev/null +++ b/Assets/Sui-Unity-SDK/Code/Sui.ZKLogin/JwtUtils.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: e2051be0817fe4b47ad9dbf6b528037e +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Sui-Unity-SDK/Tests/JwtUtilsTests.cs b/Assets/Sui-Unity-SDK/Tests/JwtUtilsTests.cs new file mode 100644 index 0000000..1a5ba85 --- /dev/null +++ b/Assets/Sui-Unity-SDK/Tests/JwtUtilsTests.cs @@ -0,0 +1,111 @@ +using System; +using NUnit.Framework; +using Sui.ZKLogin; + +[TestFixture] +public class JwtUtilsTests +{ + [Test] + public void ExtractClaimValue_ValidClaim_ReturnsCorrectValue() + { + // Arrange + var claim = new Claim + { + value = "eyJuYW1lIjoiSm9obiJ9", // Base64URL encoded '{"name":"John"}' + indexMod4 = 0 + }; + + // Act + string result = JwtUtils.ExtractClaimValue(claim, "name"); + + // Assert + Assert.AreEqual("John", result); + } + + [Test] + public void ExtractClaimValue_InvalidIndex_ThrowsException() + { + var claim = new Claim + { + value = "eyJuYW1lIjoiSm9obiJ9", + indexMod4 = 3 // Invalid index + }; + + Assert.Throws(() => + JwtUtils.ExtractClaimValue(claim, "name")); + } + + [Test] + public void ExtractClaimValue_WrongClaimName_ThrowsException() + { + var claim = new Claim + { + value = "eyJuYW1lIjoiSm9obiJ9", + indexMod4 = 0 + }; + + Assert.Throws(() => + JwtUtils.ExtractClaimValue(claim, "wrongName")); + } + + [Test] + public void ExtractClaimValue_ComplexObject_DeserializesCorrectly() + { + var claim = new Claim + { + value = "eyJ1c2VyIjp7Im5hbWUiOiJKb2huIiwiYWdlIjozMH19", + indexMod4 = 0 + }; + + var result = JwtUtils.ExtractClaimValue(claim, "user"); + + Assert.AreEqual("John", result.name); + Assert.AreEqual(30, result.age); + } + + [Test] + public void ExtractClaimValue_ShortInput_ThrowsException() + { + var claim = new Claim + { + value = "a", // Too short + indexMod4 = 0 + }; + + Assert.Throws(() => + JwtUtils.ExtractClaimValue(claim, "test")); + } + + [Test] + public void ExtractClaimValue_InvalidBase64Url_ThrowsException() + { + var claim = new Claim + { + value = "!@#$%^", // Invalid characters + indexMod4 = 0 + }; + + Assert.Throws(() => + JwtUtils.ExtractClaimValue(claim, "test")); + } + + [Test] + public void ExtractClaimValue_MultipleKeysInJson_ThrowsException() + { + var claim = new Claim + { + value = "eyJrZXkxIjoidmFsdWUxIiwia2V5MiI6InZhbHVlMiJ9", + indexMod4 = 0 + }; + + Assert.Throws(() => + JwtUtils.ExtractClaimValue(claim, "key1")); + } +} + +[Serializable] +public class UserData +{ + public string name; + public int age; +} \ No newline at end of file diff --git a/Assets/Sui-Unity-SDK/Tests/JwtUtilsTests.cs.meta b/Assets/Sui-Unity-SDK/Tests/JwtUtilsTests.cs.meta new file mode 100644 index 0000000..54d514e --- /dev/null +++ b/Assets/Sui-Unity-SDK/Tests/JwtUtilsTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: f6763c5f097cb41be8faf89677fb8aa6 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: