ballerina-platform / ballerina-library

The Ballerina Library
https://ballerina.io/learn/api-docs/ballerina/
Apache License 2.0
136 stars 58 forks source link

Add JWT public key verification inside ballerina/jwt package #6138

Closed glovekyl closed 1 week ago

glovekyl commented 6 months ago

Description

Although decoding, and verification using JWKS endpoints, and certificate file are supported. The ballerina/jwt package does not support verification of private key-signed RS256 JWT tokens using the public key.

Describe your problem(s)

The ballerina/jwt package does not support verification using the public key of a RS256 JWT token, signed using a private key.

The keys are generated using the following shell commands:

# private key
ssh-keygen -t rsa -b 4096 -m PEM -f jwtRS256.key # private key
# public key
openssl rsa -in jwtRS256.key -pubout -outform PEM -out jwtRS256.key.pub

Describe your solution(s)

Allow ballerina/jwt package to verify a JWT token using the private key, or public key. Something similar to this [frontegg.com].

In a standard typescript example. Simple signing and verification can be done as follows:

JWT tokens are signed as such:

// `process.env.JWT_PRIVATE_KEY` is base64 encoded
const privateKey = Buffer.from(process.env.JWT_PRIVATE_KEY, 'base64').toString();

const jwt = JWT.sign(UserTokenV1, privateKey, {
  algorithm: 'RS256',
  expiresIn: `${tokenTimeoutMinutes}m`,
});

Then use the public key to verify the signed token whenever needed by another service:

/**
 * Verifies the SAML token returning the UserToken if valid.
 * @param token - SAML token to verify
 * @param publicKey - base64 encoded public key for verification
 * @returns A {@link UserTokenV1} if the token is valid
 * @throws A {@link UnauthorizedError} if the token is invalid
 */
function verifySAMLToken(token: string, publicKey: string): UserTokenV1 {
  try {
    return JWT.verify(token, publicKey) as UserTokenV1;
  } catch {
    throw new UnauthorizedError('Invalid JWT');
  }
}

It is possible to create a workaround using Ballerina FFI bindings to utilise auth0/java-jwt. The following (rather inelegant) example expects a base64 encoded public key, and sets the privatekey as null.

package com.example.ballerinasample;

import com.auth0.jwt.JWT;
import com.auth0.jwt.JWTVerifier;
import com.auth0.jwt.algorithms.Algorithm;
import com.google.gson.Gson;

import java.security.KeyFactory;
import java.security.NoSuchAlgorithmException;
import java.security.PublicKey;
import java.security.interfaces.RSAPublicKey;
import java.security.spec.EncodedKeySpec;
import java.security.spec.InvalidKeySpecException;
import java.security.spec.X509EncodedKeySpec;
import java.util.Base64;

public class AuthTokenUtilities {
    public static AuthToken getVerifiedTokenFromJWT(
        String jwt,
        String base64EncodedPublicKey
    ) throws InvalidKeySpecException, NoSuchAlgorithmException {
        PublicKey publicKey = AuthTokenUtilities.decodePublicKey(
            base64EncodedPublicKey
        );
        Algorithm algorithm = Algorithm.RSA256(
            (RSAPublicKey) publicKey,
            null
        );
        JWTVerifier verifier = JWT.require(algorithm).build();

        String verifiedJWT = new String(
            Base64.getDecoder().decode(verifier.verify(jwt).getPayload())
        );
        AuthToken.VerifiedToken token = new Gson().fromJson(
            verifiedJWT,
            AuthToken.VerifiedToken.class
        );

        // essentially the JWT payload, and the token inside a class
        return new AuthToken(verifiedJWT, token);
    }

    private static PublicKey decodePublicKey(
        String base64EncodedPublicKey
    ) throws InvalidKeySpecException, NoSuchAlgorithmException {
        // Decode the base64 encoding and remove the header and footer
        String publicKeyEncoded = new String(
            Base64.getDecoder().decode(base64EncodedPublicKey)
        ).replace("-----BEGIN PUBLIC KEY-----", "")
         .replace("-----END PUBLIC KEY-----", "")
         .replace("\n", "")
         .trim();

        // The key content itself is encoded, so get byte array from the content
        byte[] publicKeyBytes = Base64.getDecoder().decode(publicKeyEncoded);

        KeyFactory kf = KeyFactory.getInstance("RSA");
        EncodedKeySpec keySpec = new X509EncodedKeySpec(publicKeyBytes);
        return kf.generatePublic(keySpec);
    }
}

Ballerina then uses this Java code through FFI bindings to verify the token using the public key, but this could easily be extended to use a public or private key.

import tenantservice.com.example.ballerinasample as sample;

# Verifies a given JWT token using the public key
# + encodedPublicKey - The base64 encoded public key used to verify the token
# + token - The JWT token to be verified
# + return - A boolean value indicating whether the token is verified or not
public isolated function verifyToken(
  string encodedPublicKey,
  string token
) returns boolean|error {
  // errors are usually of instance `sample:MediatorException`
  boolean|error verified = sample:AuthTokenBallerina_verifyToken(encodedPublicKey, token);

  if verified is error {
    log:printWarn(
      string `mediator:verifyToken() - ${verified.detail()["message"]}`,
      verified
    );
    return error("Token invalid or has expired");
  }

  return verified;
}

Suggested Labels (optional):

Suggested Assignees (optional): Based on this discord discussion: Verify JWT using public key

ayeshLK commented 3 months ago

@glovekyl we have done an improvement to the JWT module to support providing crypto:PrivateKey and crypto:PublicKey directly in the issuer/validator configurations [1] [2]

Could you please check whether this approach works for you ?

[1] - https://github.com/ballerina-platform/ballerina-library/issues/6515 [2] - https://github.com/ballerina-platform/module-ballerina-jwt/pull/1229

ayeshLK commented 1 week ago

With Ballerina JWT v2.13.0 [1] we have introduce support to directly provide crypto:PrivateKey and crypto:PublicKey directly in the issuer/validator configurations [2] With Ballerina crypto v2.7.2 [3] you can construct a crypto:PrivateKey and crypto:PublicKey using the file content [4] With those features I think we can achieve what is described in the issue. Hence, will close the issue and please do not hesitate to re-open it if you are not satisfied with the current features.

[1] - https://central.ballerina.io/ballerina/jwt/2.13.0 [2] - https://github.com/ballerina-platform/ballerina-library/issues/6515 [3] - https://central.ballerina.io/ballerina/crypto/2.7.2 [4] - https://github.com/ballerina-platform/ballerina-library/issues/6517