KEYCLOAK-13720 Specify Signature Algorithm in Signed JWT Client Authentication

This commit is contained in:
Takashi Norimatsu 2020-04-05 14:05:30 +09:00 committed by Marek Posolda
parent f0852fd362
commit 0d0617d44a
9 changed files with 129 additions and 4 deletions

View file

@ -42,6 +42,7 @@ import org.keycloak.models.AuthenticationExecutionModel;
import org.keycloak.models.ClientModel;
import org.keycloak.models.RealmModel;
import org.keycloak.models.SingleUseTokenStoreProvider;
import org.keycloak.protocol.oidc.OIDCAdvancedConfigWrapper;
import org.keycloak.protocol.oidc.OIDCLoginProtocol;
import org.keycloak.protocol.oidc.OIDCLoginProtocolService;
import org.keycloak.provider.ProviderConfigProperty;
@ -117,6 +118,20 @@ public class JWTClientAuthenticator extends AbstractClientAuthenticator {
return;
}
String expectedSignatureAlg = OIDCAdvancedConfigWrapper.fromClientModel(client).getTokenEndpointAuthSigningAlg();
if (jws.getHeader().getAlgorithm() == null || jws.getHeader().getAlgorithm().name() == null) {
Response challengeResponse = ClientAuthUtil.errorResponse(Response.Status.BAD_REQUEST.getStatusCode(), "invalid_client", "invalid signature algorithm");
context.challenge(challengeResponse);
return;
}
String actualSignatureAlg = jws.getHeader().getAlgorithm().name();
if (expectedSignatureAlg != null && !expectedSignatureAlg.equals(actualSignatureAlg)) {
Response challengeResponse = ClientAuthUtil.errorResponse(Response.Status.BAD_REQUEST.getStatusCode(), "invalid_client", "invalid signature algorithm");
context.challenge(challengeResponse);
return;
}
// Get client key and validate signature
PublicKey clientPublicKey = getSignatureValidationKey(client, context, jws);
if (clientPublicKey == null) {

View file

@ -149,6 +149,14 @@ public class OIDCAdvancedConfigWrapper {
setAttribute(OIDCConfigAttributes.ID_TOKEN_ENCRYPTED_RESPONSE_ENC, encName);
}
public String getTokenEndpointAuthSigningAlg() {
return getAttribute(OIDCConfigAttributes.TOKEN_ENDPOINT_AUTH_SIGNING_ALG);
}
public void setTokenEndpointAuthSigningAlg(String algName) {
setAttribute(OIDCConfigAttributes.TOKEN_ENDPOINT_AUTH_SIGNING_ALG, algName);
}
private String getAttribute(String attrKey) {
if (clientModel != null) {
return clientModel.getAttribute(attrKey);

View file

@ -48,6 +48,8 @@ public final class OIDCConfigAttributes {
public static final String CLIENT_SESSION_MAX_LIFESPAN = "client.session.max.lifespan";
public static final String PKCE_CODE_CHALLENGE_METHOD = "pkce.code.challenge.method";
public static final String TOKEN_ENDPOINT_AUTH_SIGNING_ALG = "token.endpoint.auth.signing.alg";
private OIDCConfigAttributes() {
}

View file

@ -133,6 +133,8 @@ public class DescriptionConverter {
configWrapper.setIdTokenEncryptedResponseEnc(clientOIDC.getIdTokenEncryptedResponseEnc());
}
configWrapper.setTokenEndpointAuthSigningAlg(clientOIDC.getTokenEndpointAuthSigningAlg());
return client;
}
@ -222,6 +224,9 @@ public class DescriptionConverter {
if (config.getIdTokenEncryptedResponseEnc() != null) {
response.setIdTokenEncryptedResponseEnc(config.getIdTokenEncryptedResponseEnc());
}
if (config.getTokenEndpointAuthSigningAlg() != null) {
response.setTokenEndpointAuthSigningAlg(config.getTokenEndpointAuthSigningAlg());
}
List<ProtocolMapperRepresentation> foundPairwiseMappers = PairwiseSubMapperUtils.getPairwiseSubMappers(client);
SubjectType subjectType = foundPairwiseMappers.isEmpty() ? SubjectType.PUBLIC : SubjectType.PAIRWISE;

View file

@ -347,6 +347,37 @@ public class OIDCClientRegistrationTest extends AbstractClientRegistrationTest {
}
}
@Test
public void testTokenEndpointSigningAlg() throws Exception {
OIDCClientRepresentation response = null;
OIDCClientRepresentation updated = null;
try {
OIDCClientRepresentation clientRep = createRep();
clientRep.setTokenEndpointAuthSigningAlg(Algorithm.ES256.toString());
response = reg.oidc().create(clientRep);
Assert.assertEquals(Algorithm.ES256.toString(), response.getTokenEndpointAuthSigningAlg());
ClientRepresentation kcClient = getClient(response.getClientId());
OIDCAdvancedConfigWrapper config = OIDCAdvancedConfigWrapper.fromClientRepresentation(kcClient);
Assert.assertEquals(Algorithm.ES256.toString(), config.getTokenEndpointAuthSigningAlg());
reg.auth(Auth.token(response));
response.setTokenEndpointAuthSigningAlg(null);
updated = reg.oidc().update(response);
Assert.assertEquals(null, response.getTokenEndpointAuthSigningAlg());
kcClient = getClient(updated.getClientId());
config = OIDCAdvancedConfigWrapper.fromClientRepresentation(kcClient);
Assert.assertEquals(null, config.getTokenEndpointAuthSigningAlg());
} finally {
// revert
reg.auth(Auth.token(updated));
updated.setTokenEndpointAuthSigningAlg(null);
reg.oidc().update(updated);
}
}
@Test
public void testOIDCEndpointCreateWithSamlClient() throws Exception {
ClientsResource clientsResource = adminClient.realm(TEST).clients();

View file

@ -52,6 +52,7 @@ import org.keycloak.crypto.SignatureSignerContext;
import org.keycloak.events.Details;
import org.keycloak.events.Errors;
import org.keycloak.events.EventType;
import org.keycloak.jose.jwe.JWEException;
import org.keycloak.jose.jws.JWSBuilder;
import org.keycloak.models.utils.KeycloakModelUtils;
import org.keycloak.protocol.oidc.OIDCAdvancedConfigWrapper;
@ -84,6 +85,7 @@ import java.io.File;
import java.io.FileInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.UnsupportedEncodingException;
import java.net.URL;
import java.nio.file.Files;
import java.security.KeyFactory;
@ -304,6 +306,27 @@ public class ClientAuthSignedJWTTest extends AbstractKeycloakTest {
testECDSASignatureLength(getClientSignedToken(Algorithm.ES512), Algorithm.ES512);
}
@Test
public void testCodeToTokenRequestSuccessES256Enforced() throws Exception {
ClientResource clientResource = null;
ClientRepresentation clientRep = null;
try {
clientResource = ApiUtil.findClientByClientId(adminClient.realm("test"), "client2");
clientRep = clientResource.toRepresentation();
OIDCAdvancedConfigWrapper.fromClientRepresentation(clientRep).setTokenEndpointAuthSigningAlg(Algorithm.ES256);
clientResource.update(clientRep);
testCodeToTokenRequestSuccess(Algorithm.ES256);
} catch (Exception e) {
Assert.fail();
} finally {
clientResource = ApiUtil.findClientByClientId(adminClient.realm("test"), "client2");
clientRep = clientResource.toRepresentation();
OIDCAdvancedConfigWrapper.fromClientRepresentation(clientRep).setTokenEndpointAuthSigningAlg(null);
clientResource.update(clientRep);
}
}
private void testECDSASignatureLength(String clientSignedToken, String alg) {
String encodedSignature = clientSignedToken.split("\\.",3)[2];
byte[] signature = Base64Url.decode(encodedSignature);
@ -911,10 +934,31 @@ public class ClientAuthSignedJWTTest extends AbstractKeycloakTest {
@Test
public void testCodeToTokenRequestFailureRS256() throws Exception {
testCodeToTokenRequestFailure(Algorithm.RS256);
testCodeToTokenRequestFailure(Algorithm.RS256, "unauthorized_client", "client_credentials_setup_required");
}
private void testCodeToTokenRequestFailure(String algorithm) throws Exception {
@Test
public void testCodeToTokenRequestFailureES256Enforced() throws Exception {
ClientResource clientResource = null;
ClientRepresentation clientRep = null;
try {
clientResource = ApiUtil.findClientByClientId(adminClient.realm("test"), "client2");
clientRep = clientResource.toRepresentation();
OIDCAdvancedConfigWrapper.fromClientRepresentation(clientRep).setTokenEndpointAuthSigningAlg(Algorithm.ES256);
clientResource.update(clientRep);
testCodeToTokenRequestFailure(Algorithm.RS256, "invalid_client", "invalid_client_credentials");
} catch (Exception e) {
Assert.fail();
} finally {
clientResource = ApiUtil.findClientByClientId(adminClient.realm("test"), "client2");
clientRep = clientResource.toRepresentation();
OIDCAdvancedConfigWrapper.fromClientRepresentation(clientRep).setTokenEndpointAuthSigningAlg(null);
clientResource.update(clientRep);
}
}
private void testCodeToTokenRequestFailure(String algorithm, String error, String description) throws Exception {
ClientRepresentation clientRepresentation = app2;
ClientResource clientResource = getClient(testRealm.getRealm(), clientRepresentation.getId());
clientRepresentation = clientResource.toRepresentation();
@ -935,13 +979,13 @@ public class ClientAuthSignedJWTTest extends AbstractKeycloakTest {
OAuthClient.AccessTokenResponse response = doAccessTokenRequest(code, getClient2SignedJWT());
assertEquals(400, response.getStatusCode());
assertEquals("unauthorized_client", response.getError());
assertEquals(error, response.getError());
events.expect(EventType.CODE_TO_TOKEN_ERROR)
.client("client2")
.session((String) null)
.clearDetails()
.error("client_credentials_setup_required")
.error(description)
.user((String) null)
.assertEvent();
} finally {

View file

@ -414,6 +414,8 @@ gen-client-private-key=Generate Client Private Key
generate-private-key=Generate Private Key
kid=Kid
kid.tooltip=KID (Key ID) of the client public key from imported JWKS.
token-endpoint-auth-signing-alg=Signature Algorithm
token-endpoint-auth-signing-alg.tooltip=JWA algorithm, which the client needs to use when signing a JWT for authentication. If left blank, the client is allowed to use any algorithm.
use-jwks-url=Use JWKS URL
use-jwks-url.tooltip=If the switch is on, client public keys will be downloaded from given JWKS URL. This allows great flexibility because new keys will be always re-downloaded again when client generates new keypair. If the switch is off, public key (or certificate) from the Keycloak DB is used, so when client keypair changes, you always need to import new key (or certificate) to the Keycloak DB as well.
jwks-url=JWKS URL

View file

@ -227,6 +227,8 @@ module.controller('ClientSignedJWTCtrl', function($scope, $location, Client, Cli
}
}, true);
$scope.tokenEndpointAuthSigningAlg = $scope.client.attributes['token.endpoint.auth.signing.alg'];
if ($scope.client.attributes["use.jwks.url"]) {
if ($scope.client.attributes["use.jwks.url"] == "true") {
$scope.useJwksUrl = true;
@ -240,6 +242,7 @@ module.controller('ClientSignedJWTCtrl', function($scope, $location, Client, Cli
}
$scope.save = function() {
$scope.client.attributes['token.endpoint.auth.signing.alg'] = $scope.tokenEndpointAuthSigningAlg;
if ($scope.useJwksUrl == true) {
$scope.client.attributes["use.jwks.url"] = "true";

View file

@ -1,5 +1,20 @@
<div class="form-horizontal no-margin-top" name="keyForm" novalidate kc-read-only="!client.access.configure" data-ng-controller="ClientSignedJWTCtrl">
<div class="form-group">
<label class="col-md-2 control-label" for="tokenEndpointAuthSigningAlg">{{:: 'token-endpoint-auth-signing-alg' | translate}}</label>
<div class="col-sm-6">
<div>
<select class="form-control" id="tokenEndpointAuthSigningAlg"
ng-change="switchChange()"
ng-model="tokenEndpointAuthSigningAlg">
<option value=""></option>
<option ng-repeat="provider in serverInfo.listProviderIds('clientSignature')" value="{{provider}}">{{provider}}</option>
</select>
</div>
</div>
<kc-tooltip>{{:: 'token-endpoint-auth-signing-alg.tooltip' | translate}}</kc-tooltip>
</div>
<div class="form-group">
<label class="col-md-2 control-label" for="useJwksUrl">{{:: 'use-jwks-url' | translate}}</label>
<div class="col-sm-6">