mirror of
https://github.com/keycloak/keycloak.git
synced 2026-01-09 23:12:06 -03:30
Improve JWT Assertion Validation using client validators
Closes #43642 Signed-off-by: rmartinc <rmartinc@redhat.com>
This commit is contained in:
parent
f27982aeb7
commit
f92adda310
@ -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();
|
||||
}
|
||||
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
@ -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();
|
||||
|
||||
@ -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();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -57,6 +57,10 @@ public class OAuthIdentityProvider {
|
||||
return new OAuthIdentityProviderKeys(config);
|
||||
}
|
||||
|
||||
public OAuthIdentityProviderKeys getKeys() {
|
||||
return keys;
|
||||
}
|
||||
|
||||
public int getKeysRequestCount() {
|
||||
return keysRequestCount;
|
||||
}
|
||||
|
||||
@ -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) {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user