Details
diff --git a/services/src/main/java/org/keycloak/protocol/oidc/OIDCConfigAttributes.java b/services/src/main/java/org/keycloak/protocol/oidc/OIDCConfigAttributes.java
index bc9960d..a1fbf74 100644
--- a/services/src/main/java/org/keycloak/protocol/oidc/OIDCConfigAttributes.java
+++ b/services/src/main/java/org/keycloak/protocol/oidc/OIDCConfigAttributes.java
@@ -39,6 +39,8 @@ public final class OIDCConfigAttributes {
public static final String ACCESS_TOKEN_SIGNED_RESPONSE_ALG = "access.token.signed.response.alg";
+ public static final String ACCESS_TOKEN_LIFESPAN = "access.token.lifespan";
+
private OIDCConfigAttributes() {
}
diff --git a/services/src/main/java/org/keycloak/protocol/oidc/TokenManager.java b/services/src/main/java/org/keycloak/protocol/oidc/TokenManager.java
index bc9e54c..f87e115 100755
--- a/services/src/main/java/org/keycloak/protocol/oidc/TokenManager.java
+++ b/services/src/main/java/org/keycloak/protocol/oidc/TokenManager.java
@@ -648,7 +648,7 @@ public class TokenManager {
token.setSessionState(session.getId());
- token.expiration(getTokenExpiration(realm, session, clientSession));
+ token.expiration(getTokenExpiration(realm, client, session, clientSession));
Set<String> allowedOrigins = client.getWebOrigins();
if (allowedOrigins != null) {
@@ -657,15 +657,32 @@ public class TokenManager {
return token;
}
- private int getTokenExpiration(RealmModel realm, UserSessionModel userSession, AuthenticatedClientSessionModel clientSession) {
+ private int getTokenExpiration(RealmModel realm, ClientModel client, UserSessionModel userSession, AuthenticatedClientSessionModel clientSession) {
boolean implicitFlow = false;
String responseType = clientSession.getNote(OIDCLoginProtocol.RESPONSE_TYPE_PARAM);
if (responseType != null) {
implicitFlow = OIDCResponseType.parse(responseType).isImplicitFlow();
}
- int tokenLifespan = implicitFlow ? realm.getAccessTokenLifespanForImplicitFlow() : realm.getAccessTokenLifespan();
- int expiration = Time.currentTime() + tokenLifespan;
+ int tokenLifespan;
+
+ if (implicitFlow) {
+ tokenLifespan = realm.getAccessTokenLifespanForImplicitFlow();
+ } else {
+ String clientLifespan = client.getAttribute(OIDCConfigAttributes.ACCESS_TOKEN_LIFESPAN);
+ if (clientLifespan != null && !clientLifespan.trim().isEmpty()) {
+ tokenLifespan = Integer.parseInt(clientLifespan);
+ } else {
+ tokenLifespan = realm.getAccessTokenLifespan();
+ }
+ }
+
+ int expiration;
+ if (tokenLifespan == -1) {
+ expiration = userSession.getStarted() + realm.getSsoSessionMaxLifespan();
+ } else {
+ expiration = Time.currentTime() + tokenLifespan;
+ }
if (!userSession.isOffline()) {
int sessionExpires = userSession.getStarted() + realm.getSsoSessionMaxLifespan();
diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oauth/AccessTokenTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oauth/AccessTokenTest.java
index fde53dc..23bc245 100755
--- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oauth/AccessTokenTest.java
+++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oauth/AccessTokenTest.java
@@ -45,6 +45,7 @@ import org.keycloak.models.ProtocolMapperModel;
import org.keycloak.models.UserModel;
import org.keycloak.models.utils.KeycloakModelUtils;
import org.keycloak.models.utils.ModelToRepresentation;
+import org.keycloak.protocol.oidc.OIDCConfigAttributes;
import org.keycloak.protocol.oidc.OIDCLoginProtocolService;
import org.keycloak.protocol.oidc.mappers.HardcodedClaim;
import org.keycloak.representations.AccessToken;
@@ -90,6 +91,7 @@ import static org.hamcrest.Matchers.allOf;
import static org.hamcrest.Matchers.greaterThanOrEqualTo;
import static org.hamcrest.Matchers.lessThanOrEqualTo;
import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNotEquals;
import static org.junit.Assert.assertNotNull;
import static org.junit.Assert.assertNull;
import static org.junit.Assert.assertTrue;
@@ -193,7 +195,7 @@ public class AccessTokenTest extends AbstractKeycloakTest {
AccessToken token = oauth.verifyToken(response.getAccessToken());
assertEquals(findUserByUsername(adminClient.realm("test"), "test-user@localhost").getId(), token.getSubject());
- Assert.assertNotEquals("test-user@localhost", token.getSubject());
+ assertNotEquals("test-user@localhost", token.getSubject());
assertEquals(sessionId, token.getSessionState());
@@ -1088,6 +1090,50 @@ public class AccessTokenTest extends AbstractKeycloakTest {
}
}
+ @Test
+ public void clientAccessTokenLifespanOverride() {
+ ClientResource client = ApiUtil.findClientByClientId(adminClient.realm("test"), "test-app");
+ ClientRepresentation clientRep = client.toRepresentation();
+
+ RealmResource realm = adminClient.realm("test");
+ RealmRepresentation rep = realm.toRepresentation();
+
+ int sessionMax = rep.getSsoSessionMaxLifespan();
+ int accessTokenLifespan = rep.getAccessTokenLifespan();
+
+ // Make sure realm lifespan is not same as client override
+ assertNotEquals(accessTokenLifespan, 500);
+
+ try {
+ clientRep.getAttributes().put(OIDCConfigAttributes.ACCESS_TOKEN_LIFESPAN, "500");
+ client.update(clientRep);
+
+ oauth.doLogin("test-user@localhost", "password");
+
+ // Check access token expires in 500 seconds as specified on client
+
+ String code = oauth.getCurrentQuery().get(OAuth2Constants.CODE);
+ OAuthClient.AccessTokenResponse response = oauth.doAccessTokenRequest(code, "password");
+ assertEquals(200, response.getStatusCode());
+
+ assertExpiration(response.getExpiresIn(), 500);
+
+ // Check access token expires when session expires
+
+ clientRep.getAttributes().put(OIDCConfigAttributes.ACCESS_TOKEN_LIFESPAN, "-1");
+ client.update(clientRep);
+
+ String refreshToken = response.getRefreshToken();
+ response = oauth.doRefreshTokenRequest(refreshToken, "password");
+ assertEquals(200, response.getStatusCode());
+
+ assertExpiration(response.getExpiresIn(), sessionMax);
+ } finally {
+ clientRep.getAttributes().put(OIDCConfigAttributes.ACCESS_TOKEN_LIFESPAN, null);
+ client.update(clientRep);
+ }
+ }
+
private void tokenRequest(String expectedRefreshAlg, String expectedAccessAlg, String expectedIdTokenAlg) throws Exception {
oauth.doLogin("test-user@localhost", "password");
@@ -1121,7 +1167,7 @@ public class AccessTokenTest extends AbstractKeycloakTest {
AccessToken token = oauth.verifyToken(response.getAccessToken());
assertEquals(findUserByUsername(adminClient.realm("test"), "test-user@localhost").getId(), token.getSubject());
- Assert.assertNotEquals("test-user@localhost", token.getSubject());
+ assertNotEquals("test-user@localhost", token.getSubject());
assertEquals(sessionId, token.getSessionState());
diff --git a/themes/src/main/resources/theme/base/admin/resources/js/controllers/clients.js b/themes/src/main/resources/theme/base/admin/resources/js/controllers/clients.js
index 588c89c..1301b21 100755
--- a/themes/src/main/resources/theme/base/admin/resources/js/controllers/clients.js
+++ b/themes/src/main/resources/theme/base/admin/resources/js/controllers/clients.js
@@ -877,7 +877,7 @@ module.controller('ClientInstallationCtrl', function($scope, realm, client, serv
});
-module.controller('ClientDetailCtrl', function($scope, realm, client, flows, $route, serverInfo, Client, ClientDescriptionConverter, Components, ClientStorageOperations, $location, $modal, Dialog, Notifications) {
+module.controller('ClientDetailCtrl', function($scope, realm, client, flows, $route, serverInfo, Client, ClientDescriptionConverter, Components, ClientStorageOperations, $location, $modal, Dialog, Notifications, TimeUnit2) {
$scope.flows = [];
$scope.clientFlows = [];
var emptyFlow = {
@@ -961,6 +961,8 @@ module.controller('ClientDetailCtrl', function($scope, realm, client, flows, $ro
// https://tools.ietf.org/html/draft-ietf-oauth-mtls-08#section-3
$scope.tlsClientCertificateBoundAccessTokens = false;
+ $scope.accessTokenLifespan = TimeUnit2.asUnit(client.attributes['access.token.lifespan']);
+
if(client.origin) {
if ($scope.access.viewRealm) {
Components.get({realm: realm.realm, componentId: client.origin}, function (link) {
@@ -1256,6 +1258,18 @@ module.controller('ClientDetailCtrl', function($scope, realm, client, flows, $ro
return false;
}
+ $scope.updateTimeouts = function() {
+ if ($scope.accessTokenLifespan.time) {
+ if ($scope.accessTokenLifespan.time === -1) {
+ $scope.clientEdit.attributes['access.token.lifespan'] = -1;
+ } else {
+ $scope.clientEdit.attributes['access.token.lifespan'] = $scope.accessTokenLifespan.toSeconds();
+ }
+ } else {
+ $scope.clientEdit.attributes['access.token.lifespan'] = null;
+ }
+ }
+
function configureAuthorizationServices() {
if ($scope.clientEdit.authorizationServicesEnabled) {
if ($scope.accessType == 'public') {
diff --git a/themes/src/main/resources/theme/base/admin/resources/js/services.js b/themes/src/main/resources/theme/base/admin/resources/js/services.js
index f53cbe5..e63a200 100755
--- a/themes/src/main/resources/theme/base/admin/resources/js/services.js
+++ b/themes/src/main/resources/theme/base/admin/resources/js/services.js
@@ -1397,23 +1397,29 @@ module.factory('TimeUnit2', function() {
var t = {};
t.asUnit = function(time) {
+
var unit = 'Minutes';
+
if (time) {
- if (time < 60) {
- time = 60;
- }
+ if (time == -1) {
+ time = -1;
+ } else {
+ if (time < 60) {
+ time = 60;
+ }
- if (time % 60 == 0) {
- unit = 'Minutes';
- time = time / 60;
- }
- if (time % 60 == 0) {
- unit = 'Hours';
- time = time / 60;
- }
- if (time % 24 == 0) {
- unit = 'Days'
- time = time / 24;
+ if (time % 60 == 0) {
+ unit = 'Minutes';
+ time = time / 60;
+ }
+ if (time % 60 == 0) {
+ unit = 'Hours';
+ time = time / 60;
+ }
+ if (time % 24 == 0) {
+ unit = 'Days'
+ time = time / 24;
+ }
}
}
diff --git a/themes/src/main/resources/theme/base/admin/resources/partials/client-detail.html b/themes/src/main/resources/theme/base/admin/resources/partials/client-detail.html
index b36172e..5253641 100755
--- a/themes/src/main/resources/theme/base/admin/resources/partials/client-detail.html
+++ b/themes/src/main/resources/theme/base/admin/resources/partials/client-detail.html
@@ -471,9 +471,26 @@
</div>
</fieldset>
- <!-- KEYCLOAK-6771 Certificate Bound Token https://tools.ietf.org/html/draft-ietf-oauth-mtls-08#section-3 -->
<fieldset data-ng-show="protocol == 'openid-connect'">
<legend collapsed><span class="text">{{:: 'advanced-client-settings' | translate}}</span> <kc-tooltip>{{:: 'advanced-client-settings.tooltip' | translate}}</kc-tooltip></legend>
+
+ <div class="form-group">
+ <label class="col-md-2 control-label" for="accessTokenLifespan">{{:: 'access-token-lifespan' | translate}}</label>
+
+ <div class="col-md-6 time-selector">
+ <input class="form-control" type="number" min="-1"
+ max="31536000" data-ng-model="accessTokenLifespan.time"
+ id="accessTokenLifespan" name="accessTokenLifespan"
+ data-ng-change="updateTimeouts()"/>
+ <select class="form-control" name="accessTokenLifespanUnit" data-ng-model="accessTokenLifespan.unit" data-ng-change="updateTimeouts()">
+ <option value="Minutes">{{:: 'minutes' | translate}}</option>
+ <option value="Hours">{{:: 'hours' | translate}}</option>
+ <option value="Days">{{:: 'days' | translate}}</option>
+ </select>
+ </div>
+ <kc-tooltip>{{:: 'access-token-lifespan.tooltip' | translate}}</kc-tooltip>
+ </div>
+
<div class="form-group clearfix block" data-ng-show="protocol == 'openid-connect'">
<label class="col-md-2 control-label" for="tlsClientCertificateBoundAccessTokens">{{:: 'tls-client-certificate-bound-access-tokens' | translate}}</label>
<div class="col-sm-6">