server: add support for ES-* ( ES256, ES384 and ES512) algorithms for signing the JWT

PR-URL: https://github.com/hasura/graphql-engine-mono/pull/9273
GitOrigin-RevId: e891a14e992e4345f5470e1e99dbfc21d9105c31
This commit is contained in:
pranshi06 2023-06-08 14:56:04 +05:30 committed by hasura-bot
parent d918e701a1
commit 1372a649df
6 changed files with 163 additions and 11 deletions

View File

@ -174,12 +174,13 @@ The JWT will normally also contain standard (`sub`, `iat` etc.) and custom (`nam
### type {#jwt-json-type}
This specifies the cryptographic signing algorithm which is used to sign the JWTs. Valid values are : `HS256`, `HS384`,
`HS512`, `RS256`, `RS384`, `RS512`, `Ed25519`. (see [https://jwt.io](https://jwt.io/)).
`HS512`, `RS256`, `RS384`, `RS512`, `Ed25519`, `ES256`, `ES384`, `ES512`. (see [https://jwt.io](https://jwt.io/)).
`HS*` is for HMAC-SHA based algorithms. `RS*` is for RSA based signing. `Ed*` is for Edwards-curve Digital Signature
algorithms. For example, if your auth server is using HMAC-SHA256 for signing the JWTs, then use `HS256`. If it is using
RSA with SHA-512, then use `RS512`. If it is using an EdDSA instance of Edwards25519, then use `Ed25519`. EC public keys
are not yet supported.
algorithms. `ES*` is for Elliptic-curve Digital Signature algorithms. For example, if your auth server is using
HMAC-SHA256 for signing the JWTs, then use `HS256`. If it is using RSA with SHA-512, then use `RS512`. If it is using an
EdDSA instance of Edwards25519, then use `Ed25519`. If it is using an ES instance of ECDSA with 256-bit curve, then use
`ES256`.
This is an optional field. This is required only if you are using the `key` property in the config.
@ -187,8 +188,8 @@ This is an optional field. This is required only if you are using the `key` prop
- In the case of a symmetric key (i.e. a HMAC-based key), just the key as is. (e.g. -"abcdef..."). The key must be long
enough for the chosen algorithm, (e.g. for HS256 it must be at least 32 characters long).
- In the case of an asymmetric key (RSA, EdDSA etc.), only the **public** key, in a PEM-encoded string or as an X509
certificate.
- In the case of an asymmetric key (RSA, EdDSA, ECDSA etc.), only the **public** key, in a PEM-encoded string or as an
X509 certificate.
This is an optional field. You can also provide a URL to fetch JWKs from using the `jwk_url` field.
@ -668,8 +669,38 @@ the public key.
```json
{
"type":"Ed25519",
"key": "-----BEGIN CERTIFICATE REQUEST-----\nMIIBAzCBtgIBADAnMQswCQYDVQQGEwJERTEYMBYGA1UEAwwPd3d3LmV4YW1wbGUu\nY29tMCowBQYDK2VwAyEA/9DV/InajW02Q0tC/tyr9mCSbSnNP1txICXVJrTGKDSg\nXDBaBgkqhkiG9w0BCQ4xTTBLMAsGA1UdDwQEAwIEMDATBgNVHSUEDDAKBggrBgEF\nBQcDATAnBgNVHREEIDAegg93d3cuZXhhbXBsZS5jb22CC2V4YW1wbGUuY29tMAUG\nAytlcANBAKbTqnTyPcf4ZkVuq2tC108pBGY19VgyoI+PP2wD2KaRz4QAO7Bjd+7S\nljyJoN83UDdtdtgb7aFgb611gx9W4go=\n-----END CERTIFICATE REQUEST-----
"
"key": "-----BEGIN CERTIFICATE REQUEST-----\nMIIBAzCBtgIBADAnMQswCQYDVQQGEwJERTEYMBYGA1UEAwwPd3d3LmV4YW1wbGUu\nY29tMCowBQYDK2VwAyEA/9DV/InajW02Q0tC/tyr9mCSbSnNP1txICXVJrTGKDSg\nXDBaBgkqhkiG9w0BCQ4xTTBLMAsGA1UdDwQEAwIEMDATBgNVHSUEDDAKBggrBgEF\nBQcDATAnBgNVHREEIDAegg93d3cuZXhhbXBsZS5jb22CC2V4YW1wbGUuY29tMAUG\nAytlcANBAKbTqnTyPcf4ZkVuq2tC108pBGY19VgyoI+PP2wD2KaRz4QAO7Bjd+7S\nljyJoN83UDdtdtgb7aFgb611gx9W4go=\n-----END CERTIFICATE REQUEST-----"
}
```
#### EC based
If your auth server is using ECDSA to sign JWTs, and is using the ES variant with a 256-bit key, the JWT config only
needs to have the public key.
**Example 1**: public key in PEM format (not OpenSSH format):
```json
{
"type": "ES256",
"key": "-----BEGIN PUBLIC KEY-----\nMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEEVs/o5+uQbTjL3chynL4wXgUg2R9\nq9UU8I5mEovUf86QZ7kOBIjJwqnzD1omageEHWwHdBO6B+dFabmdT9POxg==\n-----END PUBLIC KEY-----"
}
```
**Example 2**: public key as X509 certificate:
```json
{
"type": "ES256",
"key": "-----BEGIN CERTIFICATE-----\nMIIBbjCCARWgAwIBAgIUGn02F6Y6s88dDGmIfwiNxWxDjhswCgYIKoZIzj0EAwIw\nDTELMAkGA1UEBhMCSU4wHhcNMjMwNTI0MTAzNTI4WhcNMjgwNTIyMTAzNTI4WjAN\nMQswCQYDVQQGEwJJTjBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABBFbP6OfrkG0\n4y93Icpy+MF4FINkfavVFPCOZhKL1H/OkGe5DgSIycKp8w9aJmoHhB1sB3QTugfn\nRWm5nU/TzsajUzBRMB0GA1UdDgQWBBSaqFjzps1qG+x2DPISjaXTWsTOdDAfBgNV\nHSMEGDAWgBSaqFjzps1qG+x2DPISjaXTWsTOdDAPBgNVHRMBAf8EBTADAQH/MAoG\nCCqGSM49BAMCA0cAMEQCIBDHHWa/uLAVdGFEk82auTmw995+MsRwv52VXLw2Z+ji\nAiAXzOWIcGN8p25uhUN/7v9gEcADGIS4yUiv8gsn/Jk2ow==\n-----END CERTIFICATE-----"
}
```
**Example 3**: public key published as JWKs:
```json
{
"jwk_url": "https://www.gstatic.com/iap/verify/public_key-jwk"
}
```

View File

@ -91,7 +91,7 @@ import Hasura.HTTP
import Hasura.Logging (Hasura, LogLevel (..), Logger (..))
import Hasura.Prelude
import Hasura.RQL.Types.Roles (RoleName, mkRoleName)
import Hasura.Server.Auth.JWT.Internal (parseEdDSAKey, parseHmacKey, parseRsaKey)
import Hasura.Server.Auth.JWT.Internal (parseEdDSAKey, parseEsKey, parseHmacKey, parseRsaKey)
import Hasura.Server.Auth.JWT.Logging
import Hasura.Server.Utils
( executeJSONPath,
@ -837,7 +837,10 @@ instance J.FromJSON JWTConfig where
"RS384" -> runEither $ parseRsaKey rawKey
"RS512" -> runEither $ parseRsaKey rawKey
"Ed25519" -> runEither $ parseEdDSAKey rawKey
-- TODO(from master): support ES256, ES384, ES512, PS256, PS384, Ed448 (JOSE doesn't support it as of now)
"ES256" -> runEither $ parseEsKey rawKey
"ES384" -> runEither $ parseEsKey rawKey
"ES512" -> runEither $ parseEsKey rawKey
-- TODO(from master): support PS256, PS384, Ed448 (JOSE doesn't support it as of now)
_ -> invalidJwk ("Key type: " <> T.unpack keyType <> " is not supported")
runEither = either (invalidJwk . T.unpack) return

View File

@ -2,6 +2,7 @@ module Hasura.Server.Auth.JWT.Internal
( parseEdDSAKey,
parseHmacKey,
parseRsaKey,
parseEsKey,
)
where
@ -46,6 +47,12 @@ parseEdDSAKey key = do
err e = "Could not decode PEM: " <> e
onLeft res (Left . err)
parseEsKey :: Text -> Either Text JWK
parseEsKey key = do
let res = fromRawPem (unUTF8 $ fromText key)
err e = "Could not decode PEM: " <> e
onLeft res (Left . err)
-- | Helper functions to decode PEM bytestring to RSA public key
-- try PKCS first, then x509
@ -92,7 +99,8 @@ fromX509Pem s = do
case pubKey of
X509.PubKeyRSA pk -> return $ X509.PubKeyRSA pk
X509.PubKeyEd25519 pk -> return $ X509.PubKeyEd25519 pk
_ -> Left "Could not decode RSA or EdDSA public key from x509 cert"
X509.PubKeyEC pk -> return $ X509.PubKeyEC pk
_ -> Left "Could not decode RSA, EdDSA or EC public key from x509 cert"
pubKeyToJwk :: X509.PubKey -> Either Text JWK
pubKeyToJwk pubKey = do
@ -104,6 +112,11 @@ pubKeyToJwk pubKey = do
return $ fromKeyMaterial $ RSAKeyMaterial (rsaKeyParams n e)
X509.PubKeyEd25519 pubKeyEd ->
return $ fromKeyMaterial $ OKPKeyMaterial (Ed25519Key pubKeyEd Nothing)
X509.PubKeyEC pubKeyEc ->
case ecParametersFromX509 pubKeyEc of
Nothing -> Left "Error getting EC parameters from the public key"
Just ecKeyParameters ->
return $ fromKeyMaterial $ ECKeyMaterial ecKeyParameters
_ -> Left "This key type is not supported"
rsaKeyParams n e =
RSAKeyParameters (Base64Integer n) (Base64Integer e) Nothing

View File

@ -642,6 +642,8 @@ def jwt_configuration(
configuration = fixtures.jwt.init_rsa(tmp_path, configuration)
case 'ed25519':
configuration = fixtures.jwt.init_ed25519(tmp_path, configuration)
case 'es':
configuration = fixtures.jwt.init_es256(tmp_path, configuration)
case _:
raise Exception(f'Unsupported JWT configuration: {marker.args!r}')

View File

@ -58,3 +58,26 @@ def init_ed25519(tmp_path: pathlib.Path, configuration: Any) -> JWTConfiguration
algorithm = 'EdDSA',
server_configuration = server_configuration,
)
def init_es256(tmp_path: pathlib.Path, configuration: Any) -> JWTConfiguration:
private_key_file = tmp_path / 'private.key'
public_key_file = tmp_path / 'public.key'
subprocess.run(['openssl', 'ecparam', '-name', 'prime256v1', '-genkey', '-noout', '-out', private_key_file], check=True, capture_output=True)
subprocess.run(['openssl', 'ec', '-in', private_key_file, '-pubout', '-out', public_key_file], check=True, capture_output=True)
with open(private_key_file) as f:
private_key = f.read()
with open(public_key_file) as f:
public_key = f.read()
server_configuration = {
'type': 'ES256',
'key': public_key,
**configuration,
}
return JWTConfiguration(
private_key_file = private_key_file,
public_key_file = public_key_file,
private_key = private_key,
public_key = public_key,
algorithm = 'ES256',
server_configuration = server_configuration,
)

View File

@ -350,6 +350,11 @@ class TestJwtBasicWithEd25519(AbstractTestJwtBasic):
pass
@pytest.mark.jwt('es')
class TestJwtBasicWithEs(AbstractTestJwtBasic):
pass
@pytest.mark.jwt('rsa', {
'header': {'type': 'Cookie', 'name': 'hasura_user'},
})
@ -364,6 +369,13 @@ class TestJwtBasicWithEd25519AndCookie(AbstractTestJwtBasic):
pass
@pytest.mark.jwt('es', {
'header': {'type': 'Cookie', 'name': 'hasura_user'},
})
class TestJwtBasicWithEsAndCookie(AbstractTestJwtBasic):
pass
@pytest.mark.jwt('rsa', {
'claims_format': 'stringified_json',
})
@ -378,6 +390,13 @@ class TestJwtBasicWithEd25519AndStringifiedJsonClaims(AbstractTestJwtBasic):
pass
@pytest.mark.jwt('es', {
'claims_format': 'stringified_json',
})
class TestJwtBasicWithEsAndStringifiedJsonClaims(AbstractTestJwtBasic):
pass
@pytest.mark.jwt('rsa', {
'claims_namespace_path': '$',
})
@ -392,6 +411,13 @@ class TestJwtBasicWithEd25519AndClaimsNamespacePathAtRoot(AbstractTestJwtBasic):
pass
@pytest.mark.jwt('es', {
'claims_namespace_path': '$',
})
class TestJwtBasicWithEsAndClaimsNamespacePathAtRoot(AbstractTestJwtBasic):
pass
@pytest.mark.jwt('rsa', {
'claims_namespace_path': '$.hasura_claims',
})
@ -406,6 +432,13 @@ class TestJwtBasicWithEd25519AndClaimsNamespacePathAtOneLevelOfNesting(AbstractT
pass
@pytest.mark.jwt('es', {
'claims_namespace_path': '$.hasura_claims',
})
class TestJwtBasicWithEsAndClaimsNamespacePathAtOneLevelOfNesting(AbstractTestJwtBasic):
pass
@pytest.mark.jwt('rsa', {
'claims_namespace_path': '$.hasura.claims',
})
@ -420,6 +453,13 @@ class TestJwtBasicWithEd25519AndClaimsNamespacePathAtTwoLevelsOfNesting(Abstract
pass
@pytest.mark.jwt('es', {
'claims_namespace_path': '$.hasura.claims',
})
class TestJwtBasicWithEsAndClaimsNamespacePathAtTwoLevelsOfNesting(AbstractTestJwtBasic):
pass
@pytest.mark.jwt('rsa', {
'claims_namespace_path': '$.hasura[\'claims%\']',
})
@ -434,6 +474,13 @@ class TestJwtBasicWithEd25519AndClaimsNamespacePathWithSpecialCharacters(Abstrac
pass
@pytest.mark.jwt('es', {
'claims_namespace_path': '$.hasura[\'claims%\']',
})
class TestJwtBasicWithEsAndClaimsNamespacePathWithSpecialCharacters(AbstractTestJwtBasic):
pass
@pytest.mark.admin_secret
@pytest.mark.parametrize('endpoint', ['/v1/graphql', '/v1alpha1/graphql'])
class AbstractTestJwtExpirySkew:
@ -503,6 +550,13 @@ class TestJwtExpirySkewWithEd25519(AbstractTestJwtExpirySkew):
pass
@pytest.mark.jwt('es', {
'allowed_skew': 60,
})
class TestJwtExpirySkewWithEs(AbstractTestJwtExpirySkew):
pass
@pytest.mark.admin_secret
class AbstractTestSubscriptionJwtExpiry:
def test_jwt_expiry(self, hge_key, jwt_configuration, ws_client):
@ -540,6 +594,11 @@ class TestSubscriptionJwtExpiryWithEd25519(AbstractTestSubscriptionJwtExpiry):
pass
@pytest.mark.jwt('es')
class TestSubscriptionJwtExpiryWithEs(AbstractTestSubscriptionJwtExpiry):
pass
@pytest.mark.admin_secret
@pytest.mark.parametrize('endpoint', ['/v1/graphql', '/v1alpha1/graphql'])
class AbstractTestJwtAudienceCheck:
@ -620,6 +679,13 @@ class TestJwtAudienceCheckWithEd25519AndSingleAudience(AbstractTestJwtAudienceCh
pass
@pytest.mark.jwt('es', {
'audience': 'myapp-1234',
})
class TestJwtAudienceCheckWithEsAndSingleAudience(AbstractTestJwtAudienceCheck):
pass
@pytest.mark.jwt('rsa', {
'audience': ['myapp-1234', 'myapp-9876'],
})
@ -634,6 +700,13 @@ class TestJwtAudienceCheckWithEd25519AndListOfAudiences(AbstractTestJwtAudienceC
pass
@pytest.mark.jwt('es', {
'audience': ['myapp-1234', 'myapp-9876'],
})
class TestJwtAudienceCheckWithEsAndListOfAudiences(AbstractTestJwtAudienceCheck):
pass
@pytest.mark.admin_secret
@pytest.mark.parametrize('endpoint', ['/v1/graphql', '/v1alpha1/graphql'])
class AbstractTestJwtIssuerCheck:
@ -712,3 +785,10 @@ class TestJwtIssuerCheckWithRsa(AbstractTestJwtIssuerCheck):
})
class TestJwtIssuerCheckWithEd25519(AbstractTestJwtIssuerCheck):
pass
@pytest.mark.jwt('es', {
'issuer': 'https://hasura.com',
})
class TestJwtIssuerCheckWithEs(AbstractTestJwtIssuerCheck):
pass