fenix-hub / godot-engine.jwt

JSON Web Token library for Godot Engine written in GDScript
https://nicolosantilio.com/godot-engine.jwt
MIT License
50 stars 10 forks source link

Verify JWT using JWKS key #18

Open brownoxford opened 9 months ago

brownoxford commented 9 months ago

Feature request

I'm writing an app that uses OAuth 2.0 for user login, and I'd like to verify the JWT tokens returned by the authentication server, but there doesn't seem to be a way to validate signatures using JWKS... am I missing something?

fenix-hub commented 9 months ago

Currently there is not direct support, although I could work on an implementation. Are you using 3.x or 4.x ?

brownoxford commented 9 months ago

I’m using 4.2. It appears that gdscript just doesn’t expose the necessary crypto though ☹️

In my use case, all I get from the jwks is modulo and exponent, and I think the asn1 related libs would be needed go convert that to PEM.

On Sat, Dec 2, 2023 at 06:01 Nicolò Santilio @.***> wrote:

Currently there is not direct support, although I could work on an implementation. Are you using 3.x or 4.x ?

— Reply to this email directly, view it on GitHub https://github.com/fenix-hub/godot-engine.jwt/issues/18#issuecomment-1837120477, or unsubscribe https://github.com/notifications/unsubscribe-auth/AAFYMCLAXV3EBFM6SHTX2A3YHMDARAVCNFSM6AAAAABADXPJZ6VHI2DSMVQWIX3LMV43OSLTON2WKQ3PNVWWK3TUHMYTQMZXGEZDANBXG4 . You are receiving this because you authored the thread.Message ID: @.***>

fenix-hub commented 9 months ago

Ehi @brownoxford Yeah I was just attempting to implement the JWKS verification in the addon but we run up against the same issue. The best my addon can do is calculate the hex value of the modulus and exponent of the matching kid, but then a GDExtesion (or calling openssl from Godot engine maybe?) is required to actually calculate the RSA key.

brownoxford commented 9 months ago

I think I'm on to something - I'll post code when I have it.

brownoxford commented 9 months ago

Okay, here is what I came up with to generate an RSA public key in PEM (PKCS#1). In the end, all I had to do was properly do the ASN.1 encoding. I'm including an attached file with all of the code I generated, including the stuff I ended up not using.

This is very specific to my use case, not necessarily ready for general use, which is why I'm attaching it and not creating a PR. I believe this will be helpful if you do end up implementing this functionality.

asn1.gd.txt

extends RefCounted

## https://lapo.it/asn1js
## https://letsencrypt.org/docs/a-warm-welcome-to-asn1-and-der/

func der_encode_rsa_public_key(n: PackedByteArray, e: PackedByteArray) -> PackedByteArray:
    # Build the DER-encoded RSA public key
    var der_encoding: PackedByteArray = PackedByteArray()

    # Both modulus and exponent are positive numbers, but must be represented
    # in two's complement before encoding as integers.
    if (n[0] & 0x80):
        n.insert(0, 0x00)
    if (e[0] & 0x80):
        e.insert(0, 0x00)

    # PKCS#1
    der_encoding = encode_sequence([
        encode_integer(n),
        encode_integer(e),
    ])

    return der_encoding

func der_to_pem(der_encoding: PackedByteArray, key_type: String = "RSA PUBLIC KEY") -> String:
    var base64_encoded = Marshalls.raw_to_base64(der_encoding)

    # Break the base64-encoded string into lines for better readability
    var pem_lines = []
    while base64_encoded.length() > 0:
        var chunk = base64_encoded.left(64)
        pem_lines.append(chunk)
        base64_encoded = base64_encoded.substr(64, base64_encoded.length() - 64)

    # Construct the PEM-encoded key
    var pem_encoding = "-----BEGIN " + key_type + "-----\n"
    pem_encoding += "\n".join(pem_lines)
    pem_encoding += "\n-----END " + key_type + "-----\n"

    return pem_encoding

func encode_integer(value: PackedByteArray) -> PackedByteArray:
    # Encode an integer in DER format
    var encoding: PackedByteArray = PackedByteArray()
    encoding.append(0x02)  # INTEGER tag
    encoding += encode_length(value.size())
    encoding += value
    return encoding

func encode_sequence(values: Array[PackedByteArray]) -> PackedByteArray:
    var concatenated: PackedByteArray = PackedByteArray()
    for value in values:
        concatenated += value

    # Encode a sequence in DER format
    var encoding: PackedByteArray = PackedByteArray()
    encoding.append(0x30)  # SEQUENCE tag
    encoding += encode_length(concatenated.size())
    encoding += concatenated
    return encoding

func encode_length(length: int) -> PackedByteArray:
    # Encode the length in DER format
    var encoding: PackedByteArray = PackedByteArray()

    if length < 128:
        encoding.append(length)
    else:
        var length_bytes: PackedByteArray = PackedByteArray()

        while length > 0:
            length_bytes.insert(0, length % 256)
            length /= 256

        encoding.append(0x80 | length_bytes.size())
        encoding += length_bytes

    return encoding

Usage Example


var asn1 = load("res://oauth2/asn1.gd").new()

## Extract RS256 public key from JWKS
func _extract_public_key(jwks: Dictionary, kid: String) -> String:
    # TODO: This should be more robust, in case fields are missing
    var keys = jwks["keys"]
    for key in keys:
        if key["kid"] != kid or key["kty"] != "RSA":
            continue

        var modulus = JWTUtils.base64URL_decode(key["n"])
        var exponent = JWTUtils.base64URL_decode(key["e"])

        var key_der = asn1.der_encode_rsa_public_key(modulus, exponent)
        var key_pem = asn1.der_to_pem(key_der)

        return key_pem

return ""

var jwks = "<a json web keyset>"
var kid_to_extract = "JWT-Signature-Key"

var public_key_pem = _extract_public_key(jwks, kid_to_extract)
if public_key_pem.is_empty():
    print("could not extract key")

var public_key: CryptoKey = CryptoKey.new()
var err = public_key.load_from_string(public_key_pem, true)
if err != OK:
    print("unable to load cryptokey from pem string: %s" % error_string(err))

var jwt_algorithm: JWTAlgorithm = JWTAlgorithmBuilder.RSA256(public_key)
var jwt_verifier: JWTVerifier = JWT.require(jwt_algorithm) \
    .accept_expire_at(Time.get_unix_time_from_system()) \
    .accept_issued_at(Time.get_unix_time_from_system()) \
    .build() # Reusable Verifier
fenix-hub commented 9 months ago

Looks legit, I'll make sure to adapt it to the library in the hope that Crypto class will eventually expose some utility functions to generate certificates from modulus and exponent. Thank you very much for your time.