Improve JWT Assertion Validation using client validators

Closes #43642

Signed-off-by: rmartinc <rmartinc@redhat.com>
This commit is contained in:
rmartinc 2025-10-23 13:46:48 +02:00 committed by Marek Posolda
parent f27982aeb7
commit f92adda310
10 changed files with 334 additions and 275 deletions

View File

@ -18,5 +18,8 @@ package org.keycloak.broker.provider;
import org.keycloak.protocol.oidc.JWTAuthorizationGrantValidationContext;
public interface JWTAuthorizationGrantProvider {
BrokeredIdentityContext validateAuthorizationGrantAssertion(JWTAuthorizationGrantValidationContext assertion);
BrokeredIdentityContext validateAuthorizationGrantAssertion(JWTAuthorizationGrantValidationContext assertion) throws IdentityBrokerException;
int getAllowedClockSkew();
}

View File

@ -1,151 +1,22 @@
package org.keycloak.protocol.oidc;
import org.keycloak.OAuth2Constants;
import org.keycloak.common.util.Time;
import org.keycloak.jose.jws.JWSInput;
import org.keycloak.models.ClientModel;
import org.keycloak.representations.IDToken;
import org.keycloak.representations.JsonWebToken;
import java.util.Collections;
import java.util.List;
public class JWTAuthorizationGrantValidationContext {
public interface JWTAuthorizationGrantValidationContext {
private final String assertion;
String getAssertion();
private final ClientModel client;
JsonWebToken getJWT();
private JsonWebToken jwt;
JWSInput getJws();
private final String expectedAudience;
private JWSInput jws;
private final long currentTime;
public JWTAuthorizationGrantValidationContext(String assertion, ClientModel client, String expectedAudience) {
this.assertion = assertion;
this.client = client;
this.expectedAudience = expectedAudience;
this.currentTime = Time.currentTimeMillis();
default String getIssuer() {
return getJWT().getIssuer();
}
public void validateJWTFormat() {
try {
this.jws = new JWSInput(assertion);
this.jwt = jws.readJsonContent(JsonWebToken.class);
}
catch (Exception e) {
failure("The provided assertion is not a valid JWT");
}
}
public void validateAssertionParameters() {
if (assertion == null) {
failure("Missing parameter:" + OAuth2Constants.ASSERTION);
}
}
public void validateClient() {
if (client.isPublicClient()) {
failure("Public client not allowed to use authorization grant");
}
String val = client.getAttribute(OIDCConfigAttributes.JWT_AUTHORIZATION_GRANT_ENABLED);
if (!Boolean.parseBoolean(val)) {
throw new RuntimeException("JWT Authorization Grant is not supported for the requested client");
}
}
public void validateTokenActive() {
JsonWebToken token = getJWT();
int allowedClockSkew = getAllowedClockSkew();
int maxExp = getMaximumExpirationTime();
long lifespan;
if (token.getExp() == null) {
failure("Token exp claim is required");
}
if (!token.isActive(allowedClockSkew)) {
failure("Token is not active");
}
lifespan = token.getExp() - currentTime;
if (token.getIat() == null) {
if (lifespan > maxExp) {
failure("Token expiration is too far in the future and iat claim not present in token");
}
} else {
if (token.getIat() - allowedClockSkew > currentTime) {
failure("Token was issued in the future");
}
lifespan = Math.min(lifespan, maxExp);
if (lifespan <= 0) {
failure("Token is not active");
}
if (currentTime > token.getIat() + maxExp) {
failure("Token was issued too far in the past to be used now");
}
}
}
public void validateAudience() {
JsonWebToken token = getJWT();
List<String> expectedAudiences = getExpectedAudiences();
if (!token.hasAnyAudience(expectedAudiences)) {
failure("Invalid token audience");
}
}
public void validateIssuer() {
if (jwt == null || jwt.getIssuer() == null) {
failure("Missing claim: " + OAuth2Constants.ISSUER);
}
}
public void validateSubject() {
if (jwt == null || jwt.getSubject() == null) {
failure("Missing claim: " + IDToken.SUBJECT);
}
}
public void failure(String errorMessage) {
throw new RuntimeException(errorMessage);
}
public JsonWebToken getJWT() {
return jwt;
}
public JWSInput getJws() {
return jws;
}
public String getIssuer() {
return jwt.getIssuer();
}
public String getSubject() {
return jwt.getSubject();
}
public String getAssertion() {
return assertion;
}
private List<String> getExpectedAudiences() {
return Collections.singletonList(expectedAudience);
}
private int getAllowedClockSkew() {
return 15;
}
private int getMaximumExpirationTime() {
return 300;
default String getSubject() {
return getJWT().getSubject();
}
}

View File

@ -0,0 +1,152 @@
/*
* Copyright 2025 Red Hat, Inc. and/or its affiliates
* and other contributors as indicated by the @author tags.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
*
* See the License for the specific language governing permissions and
* limitations under the License.
*
*/
package org.keycloak.authentication.authenticators.client;
import java.util.List;
import org.jboss.logging.Logger;
import org.keycloak.common.util.Time;
import org.keycloak.jose.jws.JWSInput;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.SingleUseObjectProvider;
import org.keycloak.representations.JsonWebToken;
/**
* Base validator for JWT authorization grant and JWT client validators.
*
* @author rmartinc
*/
public abstract class AbstractBaseJWTValidator {
private static final Logger logger = Logger.getLogger(AbstractBaseJWTValidator.class);
protected final ClientAssertionState clientAssertionState;
protected final KeycloakSession session;
protected final int currentTime;
public AbstractBaseJWTValidator(KeycloakSession session, ClientAssertionState clientAssertionState) {
this.session = session;
this.clientAssertionState = clientAssertionState;
this.currentTime = Time.currentTime();
}
public ClientAssertionState getState() {
return clientAssertionState;
}
public String getClientAssertion() {
return clientAssertionState.getClientAssertion();
}
public JWSInput getJws() {
return clientAssertionState.getJws();
}
public boolean validateTokenActive(int allowedClockSkew, int maxExp, boolean reusePermitted) {
JsonWebToken token = clientAssertionState.getToken();
long lifespan;
if (token.getExp() == null) {
return failure("Token exp claim is required");
}
if (!token.isActive(allowedClockSkew)) {
return failure("Token is not active");
}
lifespan = token.getExp() - currentTime;
if (token.getIat() == null) {
if (lifespan > maxExp) {
return failure("Token expiration is too far in the future and iat claim not present in token");
}
} else {
if (token.getIat() - allowedClockSkew > currentTime) {
return failure("Token was issued in the future");
}
lifespan = Math.min(lifespan, maxExp);
if (lifespan <= 0) {
return failure("Token is not active");
}
if (currentTime > token.getIat() + maxExp) {
return failure("Token was issued too far in the past to be used now");
}
}
if (!reusePermitted) {
if (token.getId() == null) {
return failure("Token jti claim is required");
}
if (!validateTokenReuse(lifespan)) {
return false;
}
}
return true;
}
protected boolean validateTokenReuse(long lifespanInSecs) {
final JsonWebToken token = clientAssertionState.getToken();
final String tokenId = token.getId();
SingleUseObjectProvider singleUseCache = session.singleUseObjects();
if (singleUseCache.putIfAbsent(tokenId, lifespanInSecs)) {
logger.tracef("Added token '%s' to single-use cache. Lifespan: %d seconds, issuedFor: %s", tokenId, lifespanInSecs, token.getIssuedFor());
} else {
logger.warnf("Token '%s' already used when for issuedFor '%s'.", tokenId, token.getIssuedFor());
return failure("Token reuse detected");
}
return true;
}
public boolean validateTokenAudience(List<String> expectedAudiences, boolean multipleAudienceAllowed) {
JsonWebToken token = clientAssertionState.getToken();
if (!token.hasAnyAudience(expectedAudiences)) {
return failure("Invalid token audience");
}
if (!multipleAudienceAllowed && token.getAudience().length > 1) {
return failure("Multiple audiences not allowed");
}
return true;
}
public boolean validateSignatureAlgorithm(String expectedSignatureAlg) {
JWSInput jws = clientAssertionState.getJws();
if (jws.getHeader().getAlgorithm() == null) {
return failure("Invalid signature algorithm");
}
if (expectedSignatureAlg != null) {
if (!expectedSignatureAlg.equals(jws.getHeader().getAlgorithm().name())) {
return failure("Invalid signature algorithm");
}
}
return true;
}
private boolean failure(String errorDescription) {
failureCallback(errorDescription);
return false;
}
protected abstract void failureCallback(String errorDescription);
}

View File

@ -20,46 +20,35 @@
package org.keycloak.authentication.authenticators.client;
import jakarta.ws.rs.core.Response;
import java.util.List;
import org.jboss.logging.Logger;
import org.keycloak.OAuth2Constants;
import org.keycloak.OAuthErrorException;
import org.keycloak.authentication.AuthenticationFlowError;
import org.keycloak.authentication.ClientAuthenticationFlowContext;
import org.keycloak.common.util.Time;
import org.keycloak.jose.jws.JWSInput;
import org.keycloak.models.ClientModel;
import org.keycloak.models.RealmModel;
import org.keycloak.models.SingleUseObjectProvider;
import org.keycloak.representations.JsonWebToken;
import java.util.List;
/**
* Common validation for JWT client authentication with private_key_jwt or with client_secret
*
* @author <a href="mailto:mposolda@redhat.com">Marek Posolda</a>
*/
public abstract class AbstractJWTClientValidator {
public abstract class AbstractJWTClientValidator extends AbstractBaseJWTValidator {
private static final Logger logger = Logger.getLogger(AbstractJWTClientValidator.class);
protected final ClientAuthenticationFlowContext context;
protected final RealmModel realm;
protected final int currentTime;
protected final SignatureValidator signatureValidator;
protected final String clientAuthenticatorProviderId;
protected String expectedClientAssertionType = OAuth2Constants.CLIENT_ASSERTION_TYPE_JWT;
protected final ClientAssertionState clientAssertionState;
protected ClientModel client;
public AbstractJWTClientValidator(ClientAuthenticationFlowContext context, SignatureValidator signatureValidator, String clientAuthenticatorProviderId) throws Exception {
super(context.getSession(), context.getState(ClientAssertionState.class, ClientAssertionState.supplier()));
this.context = context;
this.clientAssertionState = context.getState(ClientAssertionState.class, ClientAssertionState.supplier());
this.realm = context.getRealm();
this.signatureValidator = signatureValidator;
this.currentTime = Time.currentTime();
this.clientAuthenticatorProviderId = clientAuthenticatorProviderId;
}
@ -67,29 +56,17 @@ public abstract class AbstractJWTClientValidator {
return context;
}
public ClientAssertionState getState() {
return clientAssertionState;
}
public String getClientAssertion() {
return clientAssertionState.getClientAssertion();
}
public JWSInput getJws() {
return clientAssertionState.getJws();
}
public ClientModel getClient() {
return client;
return clientAssertionState.getClient();
}
public boolean validate() {
return validateClientAssertionParameters() &&
validateClient() &&
validateSignatureAlgorithm() &&
validateSignatureAlgorithm(getExpectedSignatureAlgorithm()) &&
validateSignature() &&
validateTokenAudience() &&
validateTokenActive();
validateTokenAudience(getExpectedAudiences(), isMultipleAudienceAllowed()) &&
validateTokenActive(getAllowedClockSkew(), getMaximumExpirationTime(), isReusePermitted());
}
private boolean validateClientAssertionParameters() {
@ -132,7 +109,7 @@ public abstract class AbstractJWTClientValidator {
return false;
}
client = clientAssertionState.getClient();
ClientModel client = clientAssertionState.getClient();
if (client == null) {
return failure(AuthenticationFlowError.CLIENT_NOT_FOUND);
@ -153,98 +130,10 @@ public abstract class AbstractJWTClientValidator {
return true;
}
private boolean validateSignatureAlgorithm() {
JWSInput jws = clientAssertionState.getJws();
if (jws.getHeader().getAlgorithm() == null) {
return failure("Invalid signature algorithm");
}
String expectedSignatureAlg = getExpectedSignatureAlgorithm();
if (expectedSignatureAlg != null) {
if (!expectedSignatureAlg.equals(jws.getHeader().getAlgorithm().name())) {
return failure("Invalid signature algorithm");
}
}
return true;
}
private boolean validateSignature() {
return signatureValidator.verifySignature(this);
}
public boolean validateTokenActive() {
JsonWebToken token = clientAssertionState.getToken();
int allowedClockSkew = getAllowedClockSkew();
int maxExp = getMaximumExpirationTime();
long lifespan;
if (token.getExp() == null) {
return failure("Token exp claim is required");
}
if (!token.isActive(allowedClockSkew)) {
return failure("Token is not active");
}
lifespan = token.getExp() - currentTime;
if (token.getIat() == null) {
if (lifespan > maxExp) {
return failure("Token expiration is too far in the future and iat claim not present in token");
}
} else {
if (token.getIat() - allowedClockSkew > currentTime) {
return failure("Token was issued in the future");
}
lifespan = Math.min(lifespan, maxExp);
if (lifespan <= 0) {
return failure("Token is not active");
}
if (currentTime > token.getIat() + maxExp) {
return failure("Token was issued too far in the past to be used now");
}
}
if (!isReusePermitted()) {
if (token.getId() == null) {
return failure("Token jti claim is required");
}
if (!validateTokenReuse(token.getId(), lifespan)) {
return false;
}
}
return true;
}
private boolean validateTokenReuse(String tokenId, long lifespanInSecs) {
SingleUseObjectProvider singleUseCache = context.getSession().singleUseObjects();
if (singleUseCache.putIfAbsent(tokenId, lifespanInSecs)) {
logger.tracef("Added token '%s' to single-use cache. Lifespan: %d seconds, client: %s", tokenId, lifespanInSecs, client.getClientId());
} else {
logger.warnf("Token '%s' already used when authenticating client '%s'.", tokenId, client.getClientId());
return failure(OAuthErrorException.INVALID_CLIENT, "Token reuse detected", Response.Status.BAD_REQUEST.getStatusCode());
}
return true;
}
private boolean validateTokenAudience() {
JsonWebToken token = clientAssertionState.getToken();
List<String> expectedAudiences = getExpectedAudiences();
if (!token.hasAnyAudience(expectedAudiences)) {
return failure("Invalid token audience");
}
if (!isMultipleAudienceAllowed() && token.getAudience().length > 1) {
return failure("Multiple audiences not allowed");
}
return true;
}
public boolean failure(String errorDescription) {
return failure(errorDescription, Response.Status.BAD_REQUEST.getStatusCode());
}
@ -267,6 +156,11 @@ public abstract class AbstractJWTClientValidator {
return false;
}
@Override
protected void failureCallback(String errorDescription) {
failure(errorDescription);
}
protected abstract String getExpectedTokenIssuer();
protected abstract List<String> getExpectedAudiences();

View File

@ -51,7 +51,7 @@ public class JWTClientValidator extends AbstractJWTClientValidator {
}
protected int getMaximumExpirationTime() {
return OIDCAdvancedConfigWrapper.fromClientModel(client).getTokenEndpointAuthSigningMaxExp();
return OIDCAdvancedConfigWrapper.fromClientModel(clientAssertionState.getClient()).getTokenEndpointAuthSigningMaxExp();
}
@Override
@ -61,7 +61,7 @@ public class JWTClientValidator extends AbstractJWTClientValidator {
@Override
protected String getExpectedSignatureAlgorithm() {
return OIDCAdvancedConfigWrapper.fromClientModel(client).getTokenEndpointAuthSigningAlg();
return OIDCAdvancedConfigWrapper.fromClientModel(clientAssertionState.getClient()).getTokenEndpointAuthSigningAlg();
}
}

View File

@ -1069,9 +1069,13 @@ public class OIDCIdentityProvider extends AbstractOAuth2IdentityProvider<OIDCIde
}
@Override
public BrokeredIdentityContext validateAuthorizationGrantAssertion(JWTAuthorizationGrantValidationContext context) {
public BrokeredIdentityContext validateAuthorizationGrantAssertion(JWTAuthorizationGrantValidationContext context) throws IdentityBrokerException {
// verify signature
if (!verify(context.getJws())) {
throw new IdentityBrokerException("Invalid signature");
}
//TODO: proper assertion validation
BrokeredIdentityContext user = new BrokeredIdentityContext(context.getJWT().getSubject(), getConfig());
user.setUsername(context.getJWT().getSubject());
user.setIdp(this);
@ -1079,4 +1083,8 @@ public class OIDCIdentityProvider extends AbstractOAuth2IdentityProvider<OIDCIde
}
@Override
public int getAllowedClockSkew() {
return getConfig().getAllowedClockSkew();
}
}

View File

@ -16,8 +16,7 @@
*/
package org.keycloak.protocol.oidc.grants;
import jakarta.ws.rs.core.Response;
import org.jboss.logging.Logger;
import org.keycloak.OAuth2Constants;
import org.keycloak.OAuthErrorException;
import org.keycloak.broker.provider.BrokeredIdentityContext;
@ -33,7 +32,6 @@ import org.keycloak.models.FederatedIdentityModel;
import org.keycloak.models.IdentityProviderModel;
import org.keycloak.models.UserModel;
import org.keycloak.models.UserSessionModel;
import org.keycloak.protocol.oidc.JWTAuthorizationGrantValidationContext;
import org.keycloak.protocol.oidc.OIDCAdvancedConfigWrapper;
import org.keycloak.protocol.oidc.OIDCLoginProtocol;
import org.keycloak.protocol.oidc.TokenManager;
@ -45,9 +43,10 @@ import org.keycloak.services.resources.IdentityBrokerService;
import org.keycloak.sessions.AuthenticationSessionModel;
import org.keycloak.sessions.RootAuthenticationSessionModel;
public class JWTAuthorizationGrantType extends OAuth2GrantTypeBase {
import jakarta.ws.rs.core.Response;
import java.util.List;
private static final Logger logger = Logger.getLogger(JWTAuthorizationGrantType.class);
public class JWTAuthorizationGrantType extends OAuth2GrantTypeBase {
@Override
public Response process(Context context) {
@ -55,22 +54,17 @@ public class JWTAuthorizationGrantType extends OAuth2GrantTypeBase {
String assertion = formParams.getFirst(OAuth2Constants.ASSERTION);
String expectedAudience = Urls.realmIssuer(session.getContext().getUri().getBaseUri(), realm.getName());
JWTAuthorizationGrantValidationContext authorizationGrantContext = new JWTAuthorizationGrantValidationContext(assertion, client, expectedAudience);
try {
JWTAuthorizationGrantValidator authorizationGrantContext = JWTAuthorizationGrantValidator.createValidator(
context.getSession(), client, assertion);
//client must be confidential
authorizationGrantContext.validateClient();
//validate assertion claim (grant_type already validated to select the grant type)
authorizationGrantContext.validateAssertionParameters();
//validate token is JWT and is valid (the signature is validated by the idp)
authorizationGrantContext.validateJWTFormat();
authorizationGrantContext.validateTokenActive();
//mandatory claims
authorizationGrantContext.validateAudience();
authorizationGrantContext.validateTokenAudience(List.of(expectedAudience), false);
authorizationGrantContext.validateIssuer();
authorizationGrantContext.validateSubject();
@ -91,6 +85,9 @@ public class JWTAuthorizationGrantType extends OAuth2GrantTypeBase {
throw new RuntimeException("Identity Provider is not configured for JWT Authorization Grant");
}
// assign the provider and perform validations associated to the jwt grant provider
authorizationGrantContext.validateTokenActive(jwtAuthorizationGrantProvider.getAllowedClockSkew(), 300, false);
//validate the JWT assertion and get the brokered identity from the idp
BrokeredIdentityContext brokeredIdentityContext = jwtAuthorizationGrantProvider.validateAuthorizationGrantAssertion(authorizationGrantContext);
if (brokeredIdentityContext == null) {
@ -103,11 +100,14 @@ public class JWTAuthorizationGrantType extends OAuth2GrantTypeBase {
if (user == null) {
throw new RuntimeException("User not found");
}
event.user(user);
event.detail(Details.USERNAME, user.getUsername());
String scopeParam = formParams.getFirst(OAuth2Constants.SCOPE);
//TODO: scopes processing
UserSessionModel userSession = new UserSessionManager(session).createUserSession(realm, user, user.getUsername(), clientConnection.getRemoteHost(), "authorization-grant", false, null, null);
event.session(userSession);
RootAuthenticationSessionModel rootAuthSession = new AuthenticationSessionManager(session).createAuthenticationSession(realm, false);
AuthenticationSessionModel authSession = createSessionModel(rootAuthSession, user, client, scopeParam);
ClientSessionContext clientSessionCtx = TokenManager.attachAuthenticationSession(this.session, userSession, authSession);

View File

@ -0,0 +1,97 @@
/*
* Copyright 2025 Red Hat, Inc. and/or its affiliates
* and other contributors as indicated by the @author tags.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
*
* See the License for the specific language governing permissions and
* limitations under the License.
*
*/
package org.keycloak.protocol.oidc.grants;
import org.keycloak.OAuth2Constants;
import org.keycloak.authentication.authenticators.client.AbstractBaseJWTValidator;
import org.keycloak.authentication.authenticators.client.ClientAssertionState;
import org.keycloak.jose.jws.JWSInput;
import org.keycloak.jose.jws.JWSInputException;
import org.keycloak.models.ClientModel;
import org.keycloak.models.KeycloakSession;
import org.keycloak.protocol.oidc.JWTAuthorizationGrantValidationContext;
import org.keycloak.protocol.oidc.OIDCConfigAttributes;
import org.keycloak.representations.IDToken;
import org.keycloak.representations.JsonWebToken;
/**
* Validator for JWT Authorization grant that extends AbstractBaseJWTValidator and
* implements the JWTAuthorizationGrantValidationContext interface.
*
* @author rmartinc
*/
public class JWTAuthorizationGrantValidator extends AbstractBaseJWTValidator implements JWTAuthorizationGrantValidationContext {
public static JWTAuthorizationGrantValidator createValidator(KeycloakSession session, ClientModel client, String assertion) {
if (assertion == null) {
throw new RuntimeException("Missing parameter:" + OAuth2Constants.ASSERTION);
}
try {
JWSInput jws = new JWSInput(assertion);
JsonWebToken jwt = jws.readJsonContent(JsonWebToken.class);
ClientAssertionState clientAssertionState = new ClientAssertionState(client, OAuth2Constants.JWT_AUTHORIZATION_GRANT, assertion, jws, jwt);
return new JWTAuthorizationGrantValidator(session, clientAssertionState);
} catch (JWSInputException e) {
throw new RuntimeException("The provided assertion is not a valid JWT");
}
}
private JWTAuthorizationGrantValidator(KeycloakSession session, ClientAssertionState clientAssertionState) {
super(session, clientAssertionState);
}
public void validateClient() {
if (clientAssertionState.getClient().isPublicClient()) {
failureCallback("Public client not allowed to use authorization grant");
}
String val = clientAssertionState.getClient().getAttribute(OIDCConfigAttributes.JWT_AUTHORIZATION_GRANT_ENABLED);
if (!Boolean.parseBoolean(val)) {
throw new RuntimeException("JWT Authorization Grant is not supported for the requested client");
}
}
public void validateIssuer() {
if (getJWT().getIssuer() == null) {
failureCallback("Missing claim: " + OAuth2Constants.ISSUER);
}
}
public void validateSubject() {
if (getJWT().getSubject() == null) {
failureCallback("Missing claim: " + IDToken.SUBJECT);
}
}
@Override
public JsonWebToken getJWT() {
return clientAssertionState.getToken();
}
@Override
public String getAssertion() {
return clientAssertionState.getClientAssertion();
}
@Override
protected void failureCallback(String errorDescription) {
throw new RuntimeException(errorDescription);
}
}

View File

@ -57,6 +57,10 @@ public class OAuthIdentityProvider {
return new OAuthIdentityProviderKeys(config);
}
public OAuthIdentityProviderKeys getKeys() {
return keys;
}
public int getKeysRequestCount() {
return keysRequestCount;
}

View File

@ -3,6 +3,7 @@ package org.keycloak.tests.oauth;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
import org.keycloak.OAuth2Constants;
import org.keycloak.broker.oidc.OIDCIdentityProviderConfig;
import org.keycloak.broker.oidc.OIDCIdentityProviderFactory;
import org.keycloak.common.Profile;
import org.keycloak.common.util.Time;
@ -143,6 +144,27 @@ public class JWTAuthorizationGrantTest {
assertFailure("User not found", response, events.poll());
}
@Test
public void testReplayToken() {
String jwt = getIdentityProvider().encodeToken(createDefaultAuthorizationGrantToken());
AccessTokenResponse response = oAuthClient.jwtAuthorizationGrantRequest(jwt).send();
assertSuccess("test-app", "basic-user", response);
response = oAuthClient.jwtAuthorizationGrantRequest(jwt).send();
assertFailure("Token reuse detected", response, events.poll());
}
@Test
public void testInvalidSignature() throws Exception {
JsonWebToken token = createDefaultAuthorizationGrantToken();
OAuthIdentityProvider.OAuthIdentityProviderKeys newKeys = getIdentityProvider().createKeys();
OAuthIdentityProvider.OAuthIdentityProviderKeys keys = getIdentityProvider().getKeys();
newKeys.getKeyWrapper().setKid(keys.getKeyWrapper().getKid());
String jwt = getIdentityProvider().encodeToken(token, newKeys);
AccessTokenResponse response = oAuthClient.jwtAuthorizationGrantRequest(jwt).send();
assertFailure("Invalid signature", response, events.poll());
}
@Test
public void testSuccessGrant() {
String jwt = getIdentityProvider().encodeToken(createDefaultAuthorizationGrantToken());
@ -204,6 +226,9 @@ public class JWTAuthorizationGrantTest {
.providerId(OIDCIdentityProviderFactory.PROVIDER_ID)
.alias(IDP_ALIAS)
.setAttribute(IdentityProviderModel.ISSUER, IDP_ISSUER)
.setAttribute(OIDCIdentityProviderConfig.VALIDATE_SIGNATURE, Boolean.TRUE.toString())
.setAttribute(OIDCIdentityProviderConfig.JWKS_URL, "http://127.0.0.1:8500/idp/jwks")
.setAttribute(OIDCIdentityProviderConfig.USE_JWKS_URL, Boolean.TRUE.toString())
.build());
return realm;
}
@ -222,6 +247,11 @@ public class JWTAuthorizationGrantTest {
AccessToken accessToken = oAuthClient.parseToken(response.getAccessToken(), AccessToken.class);
Assertions.assertEquals(expectedClientId, accessToken.getIssuedFor());
Assertions.assertEquals(username, accessToken.getPreferredUsername());
EventAssertion.assertSuccess(events.poll())
.type(EventType.LOGIN)
.clientId(expectedClientId)
.details("grant_type", OAuth2Constants.JWT_AUTHORIZATION_GRANT)
.details("username", username);
}
protected void assertFailure(String expectedErrorDescription, AccessTokenResponse response, EventRepresentation event) {