jwtk / jjwt

Java JWT: JSON Web Token for Java and Android
Apache License 2.0
10.25k stars 1.33k forks source link

Verify JWT with JWKS #663

Open EPilz opened 3 years ago

EPilz commented 3 years ago

Is there a way to verify a JWT with JWKS?

bdemers commented 3 years ago

Not directly, but it’s pretty easy to add a custom key resolver to do it. https://github.com/okta/okta-jwt-verifier-java/blob/master/impl/src/main/java/com/okta/jwt/impl/jjwt/RemoteJwkSigningKeyResolver.java

(Mobile, sorry for the brief response) If this doesn’t help let me know

EPilz commented 3 years ago

Thanks for the quick response! How is the access token then verified?

bdemers commented 3 years ago

You can use JJWT to validate an JWT access token, but each IdP will have different guidelines as to which additional claims to validate. Which IdP are you using?

Note: any recommendations from an IdP would always be in addition the standard JWT validation (which JJWT does automatically)

lhazlewood commented 3 years ago

Just a note: this will be easier when #113 is complete as JWK support is required for JWE.

stale[bot] commented 2 years ago

This issue has been automatically marked as stale due to inactivity for 60 or more days. It will be closed in 7 days if no further activity occurs.

lhazlewood commented 1 year ago

@EPilz when you created this issue, how specifically were you expecting to verify JWTs with JWKs? Do you mean like @bdemers suggested? If not, what is the use case (or usage paradigm) you wanted to support?

JWKs are fully supported in the master branch via #178, and this functionality will be released in 0.12.0. But I'm curious if there's a use case that is being requested beyond what is currently documented in the README? Please let us know, otherwise I'll close this issue (trying to close issues to prepare for the 0.12.0 release).

jkellyinsf commented 1 year ago

I'm using 0.12.1 and unclear what fully supported means. I'm expecting to be able to do something like this:

String webKeys = ... // fetch jwks.json from well-known URL
Jwk<?> jwk = Jwks.parser().build().parse(webKeys);
var payload = Jwts.parser().verifyWith(jwk).build().parse(accessToken).getPayload();

This doesn't work, and I can't figure out from the voluminous readme what I'm supposed to do with the Jwk object once I have it. I can see how I could parse it myself and then write a key locator that digs for the right kid match, but I suspect I'm missing something.

lhazlewood commented 1 year ago

@jkellyinsf Jwk has a toKey() method to represent it as a java.security.Key instance, so you can do:

Jwts.parser().verifyWith(jwk.toKey())...

Or return jwk.toKey() from a Locator<Key> implementation.

There might be a chance in a future version for Jwk to directly implement java.security.Key so you can use it without calling toKey(), but the Key interface imposes implementation burdens around getFormat() and getEncoded() that we didn't want to tackle on the last release.

Does that help? I'm happy to clarify anything that we might be missing, and then add that to the README, because odds are high that if you have questions, others will as well :)

jkellyinsf commented 1 year ago

Thanks @lhazlewood, I'm struggling with that. I can get it to compile if I cast jwk.toKey() to either PublicKey or SecretKey. But regardless the Jwk parse fails with "JWK is missing required 'kty' (Key Type) parameter," I presume because the jwks.json follows this structure and contains more than one key.

lhazlewood commented 1 year ago

@jkellyinsf that's because what what you linked to is not a Jwk, it is a JwkSet. Try:

Jwks.setParser().build().parse(jwkSetJson);
jkellyinsf commented 1 year ago

Ah, that makes sense. So that leaves me with a Set<Jwk<?>>. Do I then implement a Locator that loops through the keys and picks the one whose kid matches that in the jwt header?

lhazlewood commented 1 year ago

@jkellyinsf I think that makes sense. FWIW, depending on the size of the JwkSet, the first time you read it, you could iterate over the collection and put them in a map with the map key being the kid. Then for your Locator implementation, you could have something like:

@Override // extends from LocatorAdapter<Key>
protected Key locate(ProtectedHeader header) {
    Jwk<?> jwk = keyMap.get(header.getKeyId());
    return jwk.toKey();
}

which makes key location/lookups a constant-time operation.

Just to be careful however, if it were me, I would assert that the key being referenced in the header is allowed to be used for that particular JWS or JWE.

For example, if the header is a JwsHeader indicating a JWS is being parsed, you could check the referenced jwk's operations (via jwk.getOperations()) and if the operations exist (are not empty), but do not include Jwks.OP.SIGN, then the referenced key is not allowed to be used. (or if the Jwk is an AsymmetricJwk, check it's asymmetricJwk.getPublicKeyUse() and ensure it's allowed to be used for the particular JWS or JWE.

We're going to automate these additional kinds of checks in a future release, but we didn't have time to automate that for the 0.12.0 release.

jkellyinsf commented 1 year ago

Thanks, that's a good idea. For the benefit of future readers and GPT spiders, here's what I got to work:

// At initialization
String webKeys = Methanol.create().send(MutableRequest.GET(jwksUrl), HttpResponse.BodyHandlers.ofString()).body();
Map<String, ? extends Key> keyMap = Jwks.setParser().build()
        .parse(webKeys).getKeys().stream()
        .collect(toMap(Identifiable::getId, Jwk::toKey));
JwtParser jwtParser = Jwts.parser().keyLocator(header -> keyMap.get(header.getOrDefault("kid", "").toString())).build();

// ...

// Upon receiving a token
Claims claims = (Claims) jwtParser.parse(token).getPayload();

I appreciate the help, @lhazlewood!

lhazlewood commented 1 year ago

@jkellyinsf don't forget that all JJWT Parsers have a parse(InputStream) method, so you could pass the HTTP content stream directly, and that would have better performance, eliminating the intermediate String/byte arrays on the heap. Something like:

Jwks.setParser().build().parse(httpBody.getInputStream()).getKeys().collect...

I dunno how Methanol works or if that's possible, but food for thought.

lhazlewood commented 1 year ago

Also, if you are confident that the token payload will always be Claims, you can do the more type-safe alternative:

// Upon receiving a token
Claims claims = jwtParser.parseSignedClaims(token); // alias for parse(token).accept(Jws.CLAIMS);
rkennedy-mode commented 11 months ago

UPDATE: Please disregard, I found what I needed in https://github.com/jwtk/jjwt#jwk-private-topub.

If you have a JWKS with both the private and public key pair and use the above, you end up with the following exception:

Caused by: io.jsonwebtoken.security.InvalidKeyException: PrivateKeys may not be used to verify digital signatures. PrivateKeys are used to sign, and PublicKeys are used to verify.
    at io.jsonwebtoken.impl.DefaultJwtParser.verifySignature(DefaultJwtParser.java:298)
    at io.jsonwebtoken.impl.DefaultJwtParser.parse(DefaultJwtParser.java:577)
    at io.jsonwebtoken.impl.DefaultJwtParser.parse(DefaultJwtParser.java:362)
    at io.jsonwebtoken.impl.DefaultJwtParser.parse(DefaultJwtParser.java:94)
    at io.jsonwebtoken.impl.io.AbstractParser.parse(AbstractParser.java:36)
    at io.jsonwebtoken.impl.io.AbstractParser.parse(AbstractParser.java:29)
    at io.jsonwebtoken.impl.DefaultJwtParser.parseSignedClaims(DefaultJwtParser.java:821)

The JWKS itself looks something like this (redacted) bit of JSON:

{
    "keys": [
        {
            "p": "…",
            "kty": "…",
            "q": "…",
            "d": "…",
            "e": "…",
            "use": "…",
            "kid": "…",
            "qi": "…",
            "dp": "…",
            "alg": "…",
            "dq": "…",
            "n": "…"
        }
    ]
}

Is there a way to convert the PrivateKey down to a PublicKey for verification? Is this a silly/unsafe thing to do with JWKS/JWT (I'm new to using these things)? This application both generates and validates JWS if that makes a difference to the answer.