KEYCLOAK-1129 Implicit flow: more work

This commit is contained in:
mposolda 2015-11-27 08:29:50 +01:00
parent ef80b64d1c
commit 57b60797ce
34 changed files with 336 additions and 63 deletions

View file

@ -51,6 +51,11 @@
<constraints nullable="false"/>
</column>
</addColumn>
<addColumn tableName="REALM">
<column name="ACCESS_TOKEN_LIFE_IMPLICIT" type="INT" defaultValueNumeric="0"/>
</addColumn>
<dropColumn tableName="IDENTITY_PROVIDER" columnName="UPDATE_PROFILE_FIRST_LGN_MD"/>
<addPrimaryKey columnNames="ID" constraintName="CONSTRAINT_GROUP" tableName="KEYCLOAK_GROUP"/>

View file

@ -12,6 +12,7 @@ public class RealmRepresentation {
protected Integer notBefore;
protected Boolean revokeRefreshToken;
protected Integer accessTokenLifespan;
protected Integer accessTokenLifespanForImplicitFlow;
protected Integer ssoSessionIdleTimeout;
protected Integer ssoSessionMaxLifespan;
protected Integer offlineSessionIdleTimeout;
@ -187,6 +188,14 @@ public class RealmRepresentation {
this.accessTokenLifespan = accessTokenLifespan;
}
public Integer getAccessTokenLifespanForImplicitFlow() {
return accessTokenLifespanForImplicitFlow;
}
public void setAccessTokenLifespanForImplicitFlow(Integer accessTokenLifespanForImplicitFlow) {
this.accessTokenLifespanForImplicitFlow = accessTokenLifespanForImplicitFlow;
}
public Integer getSsoSessionIdleTimeout() {
return ssoSessionIdleTimeout;
}

View file

@ -42,6 +42,12 @@
initial login. The value of this configuration option should be however long you feel comfortable with the
application not knowing if the user's permissions have changed. This value is usually in minutes.
</para>
<para>
The <code class="literal">Access Token Lifespan For Implicit Flow</code> is how long an access token is valid for when using OpenID Connect implicit flow.
With implicit flow, there is no refresh token available, so that's why the lifespan is usually bigger than default Access Token Lifespan mentioned above.
See <ulink url="http://openid.net/specs/openid-connect-core-1_0.html#ImplicitFlowAuth">OpenID Connect specification</ulink> for details about implicit flow and
<link linkend="javascript-adapter">Javascript Adapter</link> for some additional details.
</para>
<para>
The <literal>Client login timeout</literal> is how long an access code is valid for. An access code is obtained
on the 1st leg of the OAuth 2.0 redirection protocol. This should be a short time limit. Usually seconds.

View file

@ -67,8 +67,11 @@
var o = 'Token Expires:\t\t' + new Date((keycloak.tokenParsed.exp + keycloak.timeSkew) * 1000).toLocaleString() + '\n';
o += 'Token Expires in:\t' + Math.round(keycloak.tokenParsed.exp + keycloak.timeSkew - new Date().getTime() / 1000) + ' seconds\n';
if (keycloak.refreshTokenParsed) {
o += 'Refresh Token Expires:\t' + new Date((keycloak.refreshTokenParsed.exp + keycloak.timeSkew) * 1000).toLocaleString() + '\n';
o += 'Refresh Expires in:\t' + Math.round(keycloak.refreshTokenParsed.exp + keycloak.timeSkew - new Date().getTime() / 1000) + ' seconds';
}
output(o);
}
@ -106,7 +109,11 @@
event('Auth Logout');
};
// Flow can be changed to 'implicit' or 'hybrid', but then client must enable implicit flow too in admin console
keycloak.onTokenExpired = function () {
event('Access token expired.');
};
// Flow can be changed to 'implicit' or 'hybrid', but then client must enable implicit flow in admin console too
var initOptions = {
responseMode: 'fragment',
flow: 'standard'

View file

@ -80,6 +80,8 @@ offline-session-idle=Offline Session Idle
offline-session-idle.tooltip=Time an offline session is allowed to be idle before it expires. You need to use offline token to refresh at least once within this period, otherwise offline session will expire.
access-token-lifespan=Access Token Lifespan
access-token-lifespan.tooltip=Max time before an access token is expired. This value is recommended to be short relative to the SSO timeout.
access-token-lifespan-for-implicit-flow=Access Token Lifespan For Implicit Flow
access-token-lifespan-for-implicit-flow.tooltip=Max time before an access token issued during OpenID Connect Implicit Flow is expired. This value is recommended to be shorter than SSO timeout. There is no possibility to refresh token during implicit flow, that's why there is separate timeout different to 'Access Token Lifespan'.
client-login-timeout=Client login timeout
client-login-timeout.tooltip=Max time an client has to finish the access token protocol. This should normally be 1 minute.
login-timeout=Login timeout

View file

@ -899,6 +899,12 @@ module.controller('RealmTokenDetailCtrl', function($scope, Realm, realm, $http,
$scope.realm.accessTokenLifespan = TimeUnit.convert($scope.realm.accessTokenLifespan, from, to);
});
$scope.realm.accessTokenLifespanForImplicitFlowUnit = TimeUnit.autoUnit(realm.accessTokenLifespanForImplicitFlow);
$scope.realm.accessTokenLifespanForImplicitFlow = TimeUnit.toUnit(realm.accessTokenLifespanForImplicitFlow, $scope.realm.accessTokenLifespanForImplicitFlowUnit);
$scope.$watch('realm.accessTokenLifespanForImplicitFlowUnit', function(to, from) {
$scope.realm.accessTokenLifespanForImplicitFlow = TimeUnit.convert($scope.realm.accessTokenLifespanForImplicitFlow, from, to);
});
$scope.realm.ssoSessionIdleTimeoutUnit = TimeUnit.autoUnit(realm.ssoSessionIdleTimeout);
$scope.realm.ssoSessionIdleTimeout = TimeUnit.toUnit(realm.ssoSessionIdleTimeout, $scope.realm.ssoSessionIdleTimeoutUnit);
$scope.$watch('realm.ssoSessionIdleTimeoutUnit', function(to, from) {
@ -947,6 +953,7 @@ module.controller('RealmTokenDetailCtrl', function($scope, Realm, realm, $http,
$scope.save = function() {
var realmCopy = angular.copy($scope.realm);
delete realmCopy["accessTokenLifespanUnit"];
delete realmCopy["accessTokenLifespanForImplicitFlowUnit"];
delete realmCopy["ssoSessionMaxLifespanUnit"];
delete realmCopy["offlineSessionIdleTimeoutUnit"];
delete realmCopy["accessCodeLifespanUnit"];
@ -955,6 +962,7 @@ module.controller('RealmTokenDetailCtrl', function($scope, Realm, realm, $http,
delete realmCopy["accessCodeLifespanLoginUnit"];
realmCopy.accessTokenLifespan = TimeUnit.toSeconds($scope.realm.accessTokenLifespan, $scope.realm.accessTokenLifespanUnit)
realmCopy.accessTokenLifespanForImplicitFlow = TimeUnit.toSeconds($scope.realm.accessTokenLifespanForImplicitFlow, $scope.realm.accessTokenLifespanForImplicitFlowUnit)
realmCopy.ssoSessionIdleTimeout = TimeUnit.toSeconds($scope.realm.ssoSessionIdleTimeout, $scope.realm.ssoSessionIdleTimeoutUnit)
realmCopy.ssoSessionMaxLifespan = TimeUnit.toSeconds($scope.realm.ssoSessionMaxLifespan, $scope.realm.ssoSessionMaxLifespanUnit)
realmCopy.offlineSessionIdleTimeout = TimeUnit.toSeconds($scope.realm.offlineSessionIdleTimeout, $scope.realm.offlineSessionIdleTimeoutUnit)

View file

@ -82,6 +82,23 @@
<kc-tooltip>{{:: 'access-token-lifespan.tooltip' | translate}}</kc-tooltip>
</div>
<div class="form-group">
<label class="col-md-2 control-label" for="accessTokenLifespanForImplicitFlow">{{:: 'access-token-lifespan-for-implicit-flow' | translate}}</label>
<div class="col-md-6 time-selector">
<input class="form-control" type="number" required min="1"
max="31536000" data-ng-model="realm.accessTokenLifespanForImplicitFlow"
id="accessTokenLifespanForImplicitFlow" name="accessTokenLifespanForImplicitFlow"/>
<select class="form-control" name="accessTokenLifespanForImplicitFlowUnit" data-ng-model="realm.accessTokenLifespanForImplicitFlowUnit">
<option data-ng-selected="!realm.accessTokenLifespanForImplicitFlowUnit" value="Seconds">{{:: 'seconds' | translate}}</option>
<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-for-implicit-flow.tooltip' | translate}}</kc-tooltip>
</div>
<div class="form-group">
<label class="col-md-2 control-label" for="accessCodeLifespan">{{:: 'client-login-timeout' | translate}}</label>

View file

@ -51,14 +51,15 @@
kc.responseType = 'code';
break;
case 'implicit':
kc.responseType = 'id_token token refresh_token';
kc.responseType = 'id_token token';
break;
case 'hybrid':
kc.responseType = 'code id_token token refresh_token';
kc.responseType = 'code id_token token';
break;
default:
throw 'Invalid value for flow';
}
kc.flow = initOptions.flow;
}
}
@ -67,10 +68,9 @@
}
if (!kc.responseType) {
kc.responseType = 'code';
kc.flow = 'standard';
}
console.log('responseMode=' + kc.responseMode + ', responseType=' + kc.responseType);
var promise = createPromise();
var initPromise = createPromise();
@ -128,7 +128,7 @@
return;
} else if (initOptions) {
if (initOptions.token || initOptions.refreshToken) {
setToken(initOptions.token, initOptions.refreshToken, initOptions.idToken);
setToken(initOptions.token, initOptions.refreshToken, initOptions.idToken, false);
if (loginIframe.enable) {
setupCheckLoginIframe().success(function() {
@ -313,7 +313,7 @@
}
kc.isTokenExpired = function(minValidity) {
if (!kc.tokenParsed || !kc.refreshToken) {
if (!kc.tokenParsed || (!kc.refreshToken && kc.flow != 'implicit' )) {
throw 'Not authenticated';
}
@ -363,7 +363,7 @@
timeLocal = (timeLocal + new Date().getTime()) / 2;
var tokenResponse = JSON.parse(req.responseText);
setToken(tokenResponse['access_token'], tokenResponse['refresh_token'], tokenResponse['id_token']);
setToken(tokenResponse['access_token'], tokenResponse['refresh_token'], tokenResponse['id_token'], true);
kc.timeSkew = Math.floor(timeLocal / 1000) - kc.tokenParsed.iat;
@ -401,7 +401,7 @@
kc.clearToken = function() {
if (kc.token) {
setToken(null, null, null);
setToken(null, null, null, true);
kc.onAuthLogout && kc.onAuthLogout();
if (kc.loginRequired) {
kc.login();
@ -432,7 +432,19 @@
var timeLocal = new Date().getTime();
if (code) {
if (error) {
if (prompt != 'none') {
kc.onAuthError && kc.onAuthError();
promise && promise.setError();
} else {
promise && promise.setSuccess();
}
return;
} else if ((kc.flow != 'standard') && (oauth.access_token || oauth.id_token)) {
authSuccess(oauth.access_token, null, oauth.id_token, true);
}
if ((kc.flow != 'implicit') && code) {
var params = 'code=' + code + '&grant_type=authorization_code';
var url = getRealmUrl() + '/protocol/openid-connect/token';
@ -455,7 +467,7 @@
if (req.status == 200) {
var tokenResponse = JSON.parse(req.responseText);
authSuccess(tokenResponse['access_token'], tokenResponse['refresh_token'], tokenResponse['id_token'])
authSuccess(tokenResponse['access_token'], tokenResponse['refresh_token'], tokenResponse['id_token'], kc.flow === 'standard');
} else {
kc.onAuthError && kc.onAuthError();
promise && promise.setError();
@ -464,22 +476,12 @@
};
req.send(params);
} else if (error) {
if (prompt != 'none') {
kc.onAuthError && kc.onAuthError();
promise && promise.setError();
} else {
promise && promise.setSuccess();
}
} else if (oauth.access_token || oauth.id_token || oauth.refresh_token) {
authSuccess(oauth.access_token, oauth.refresh_token, oauth.id_token);
}
function authSuccess(accessToken, refreshToken, idToken) {
function authSuccess(accessToken, refreshToken, idToken, fulfillPromise) {
timeLocal = (timeLocal + new Date().getTime()) / 2;
setToken(accessToken, refreshToken, idToken);
setToken(accessToken, refreshToken, idToken, true);
if ((kc.tokenParsed && kc.tokenParsed.nonce != oauth.storedNonce) ||
(kc.refreshTokenParsed && kc.refreshTokenParsed.nonce != oauth.storedNonce) ||
@ -491,10 +493,12 @@
} else {
kc.timeSkew = Math.floor(timeLocal / 1000) - kc.tokenParsed.iat;
if (fulfillPromise) {
kc.onAuthSuccess && kc.onAuthSuccess();
promise && promise.setSuccess();
}
}
}
}
@ -561,7 +565,12 @@
return promise.promise;
}
function setToken(token, refreshToken, idToken) {
function setToken(token, refreshToken, idToken, useTokenTime) {
if (kc.tokenTimeoutHandle) {
clearTimeout(kc.tokenTimeoutHandle);
kc.tokenTimeoutHandle = null;
}
if (token) {
kc.token = token;
kc.tokenParsed = decodeToken(token);
@ -574,6 +583,13 @@
kc.subject = kc.tokenParsed.sub;
kc.realmAccess = kc.tokenParsed.realm_access;
kc.resourceAccess = kc.tokenParsed.resource_access;
if (kc.onTokenExpired) {
var start = useTokenTime ? kc.tokenParsed.iat : (new Date().getTime() / 1000);
var expiresIn = kc.tokenParsed.exp - start;
kc.tokenTimeoutHandle = setTimeout(kc.onTokenExpired, expiresIn * 1000);
}
} else {
delete kc.token;
delete kc.tokenParsed;
@ -1022,8 +1038,6 @@
}
}
console.log("OAUTH: ");
console.log(oauth);
return oauth;
}
}

View file

@ -5,6 +5,7 @@ import org.keycloak.migration.migrators.MigrateTo1_3_0;
import org.keycloak.migration.migrators.MigrateTo1_4_0;
import org.keycloak.migration.migrators.MigrateTo1_5_0;
import org.keycloak.migration.migrators.MigrateTo1_6_0;
import org.keycloak.migration.migrators.MigrateTo1_7_0;
import org.keycloak.migration.migrators.MigrationTo1_2_0_CR1;
import org.keycloak.models.KeycloakSession;
@ -54,6 +55,12 @@ public class MigrationModelManager {
}
new MigrateTo1_6_0().migrate(session);
}
if (stored == null || stored.lessThan(MigrateTo1_7_0.VERSION)) {
if (stored != null) {
logger.debug("Migrating older model to 1.7.0 updates");
}
new MigrateTo1_7_0().migrate(session);
}
model.setStoredVersion(MigrationModel.LATEST_VERSION);
}

View file

@ -0,0 +1,23 @@
package org.keycloak.migration.migrators;
import java.util.List;
import org.keycloak.migration.ModelVersion;
import org.keycloak.models.Constants;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.RealmModel;
/**
* @author <a href="mailto:mposolda@redhat.com">Marek Posolda</a>
*/
public class MigrateTo1_7_0 {
public static final ModelVersion VERSION = new ModelVersion("1.7.0");
public void migrate(KeycloakSession session) {
List<RealmModel> realms = session.realms().getRealms();
for (RealmModel realm : realms) {
realm.setAccessTokenLifespanForImplicitFlow(Constants.DEFAULT_ACCESS_TOKEN_LIFESPAN_FOR_IMPLICIT_FLOW_TIMEOUT);
}
}
}

View file

@ -20,6 +20,8 @@ public interface Constants {
String[] BROKER_SERVICE_ROLES = {READ_TOKEN_ROLE};
String OFFLINE_ACCESS_ROLE = OAuth2Constants.OFFLINE_ACCESS;
// 15 minutes
int DEFAULT_ACCESS_TOKEN_LIFESPAN_FOR_IMPLICIT_FLOW_TIMEOUT = 900;
// 30 days
int DEFAULT_OFFLINE_SESSION_IDLE_TIMEOUT = 2592000;

View file

@ -107,6 +107,9 @@ public interface RealmModel extends RoleContainerModel {
void setAccessTokenLifespan(int seconds);
int getAccessTokenLifespanForImplicitFlow();
void setAccessTokenLifespanForImplicitFlow(int seconds);
int getAccessCodeLifespan();
void setAccessCodeLifespan(int seconds);

View file

@ -44,6 +44,7 @@ public class RealmEntity extends AbstractIdentifiableEntity {
private int ssoSessionMaxLifespan;
private int offlineSessionIdleTimeout;
private int accessTokenLifespan;
private int accessTokenLifespanForImplicitFlow;
private int accessCodeLifespan;
private int accessCodeLifespanUserAction;
private int accessCodeLifespanLogin;
@ -272,6 +273,14 @@ public class RealmEntity extends AbstractIdentifiableEntity {
this.accessTokenLifespan = accessTokenLifespan;
}
public int getAccessTokenLifespanForImplicitFlow() {
return accessTokenLifespanForImplicitFlow;
}
public void setAccessTokenLifespanForImplicitFlow(int accessTokenLifespanForImplicitFlow) {
this.accessTokenLifespanForImplicitFlow = accessTokenLifespanForImplicitFlow;
}
public int getAccessCodeLifespan() {
return accessCodeLifespan;
}

View file

@ -205,6 +205,7 @@ public class ModelToRepresentation {
rep.setEditUsernameAllowed(realm.isEditUsernameAllowed());
rep.setRevokeRefreshToken(realm.isRevokeRefreshToken());
rep.setAccessTokenLifespan(realm.getAccessTokenLifespan());
rep.setAccessTokenLifespanForImplicitFlow(realm.getAccessTokenLifespanForImplicitFlow());
rep.setSsoSessionIdleTimeout(realm.getSsoSessionIdleTimeout());
rep.setSsoSessionMaxLifespan(realm.getSsoSessionMaxLifespan());
rep.setOfflineSessionIdleTimeout(realm.getOfflineSessionIdleTimeout());

View file

@ -105,6 +105,9 @@ public class RepresentationToModel {
if (rep.getAccessTokenLifespan() != null) newRealm.setAccessTokenLifespan(rep.getAccessTokenLifespan());
else newRealm.setAccessTokenLifespan(300);
if (rep.getAccessTokenLifespanForImplicitFlow() != null) newRealm.setAccessTokenLifespanForImplicitFlow(rep.getAccessTokenLifespanForImplicitFlow());
else newRealm.setAccessTokenLifespanForImplicitFlow(Constants.DEFAULT_ACCESS_TOKEN_LIFESPAN_FOR_IMPLICIT_FLOW_TIMEOUT);
if (rep.getSsoSessionIdleTimeout() != null) newRealm.setSsoSessionIdleTimeout(rep.getSsoSessionIdleTimeout());
else newRealm.setSsoSessionIdleTimeout(1800);
if (rep.getSsoSessionMaxLifespan() != null) newRealm.setSsoSessionMaxLifespan(rep.getSsoSessionMaxLifespan());
@ -603,6 +606,7 @@ public class RepresentationToModel {
if (rep.getNotBefore() != null) realm.setNotBefore(rep.getNotBefore());
if (rep.getRevokeRefreshToken() != null) realm.setRevokeRefreshToken(rep.getRevokeRefreshToken());
if (rep.getAccessTokenLifespan() != null) realm.setAccessTokenLifespan(rep.getAccessTokenLifespan());
if (rep.getAccessTokenLifespanForImplicitFlow() != null) realm.setAccessTokenLifespanForImplicitFlow(rep.getAccessTokenLifespanForImplicitFlow());
if (rep.getSsoSessionIdleTimeout() != null) realm.setSsoSessionIdleTimeout(rep.getSsoSessionIdleTimeout());
if (rep.getSsoSessionMaxLifespan() != null) realm.setSsoSessionMaxLifespan(rep.getSsoSessionMaxLifespan());
if (rep.getOfflineSessionIdleTimeout() != null) realm.setOfflineSessionIdleTimeout(rep.getOfflineSessionIdleTimeout());

View file

@ -300,6 +300,18 @@ public class RealmAdapter implements RealmModel {
updated.setAccessTokenLifespan(seconds);
}
@Override
public int getAccessTokenLifespanForImplicitFlow() {
if (updated != null) return updated.getAccessTokenLifespanForImplicitFlow();
return cached.getAccessTokenLifespanForImplicitFlow();
}
@Override
public void setAccessTokenLifespanForImplicitFlow(int seconds) {
getDelegateForUpdate();
updated.setAccessTokenLifespanForImplicitFlow(seconds);
}
@Override
public int getAccessCodeLifespan() {
if (updated != null) return updated.getAccessCodeLifespan();

View file

@ -61,6 +61,7 @@ public class CachedRealm implements Serializable {
private int ssoSessionMaxLifespan;
private int offlineSessionIdleTimeout;
private int accessTokenLifespan;
private int accessTokenLifespanForImplicitFlow;
private int accessCodeLifespan;
private int accessCodeLifespanUserAction;
private int accessCodeLifespanLogin;
@ -146,6 +147,7 @@ public class CachedRealm implements Serializable {
ssoSessionMaxLifespan = model.getSsoSessionMaxLifespan();
offlineSessionIdleTimeout = model.getOfflineSessionIdleTimeout();
accessTokenLifespan = model.getAccessTokenLifespan();
accessTokenLifespanForImplicitFlow = model.getAccessTokenLifespanForImplicitFlow();
accessCodeLifespan = model.getAccessCodeLifespan();
accessCodeLifespanUserAction = model.getAccessCodeLifespanUserAction();
accessCodeLifespanLogin = model.getAccessCodeLifespanLogin();
@ -347,6 +349,10 @@ public class CachedRealm implements Serializable {
return accessTokenLifespan;
}
public int getAccessTokenLifespanForImplicitFlow() {
return accessTokenLifespanForImplicitFlow;
}
public int getAccessCodeLifespan() {
return accessCodeLifespan;
}

View file

@ -360,6 +360,16 @@ public class RealmAdapter implements RealmModel {
em.flush();
}
@Override
public int getAccessTokenLifespanForImplicitFlow() {
return realm.getAccessTokenLifespanForImplicitFlow();
}
@Override
public void setAccessTokenLifespanForImplicitFlow(int seconds) {
realm.setAccessTokenLifespanForImplicitFlow(seconds);
}
@Override
public int getSsoSessionIdleTimeout() {
return realm.getSsoSessionIdleTimeout();

View file

@ -86,6 +86,8 @@ public class RealmEntity {
private int offlineSessionIdleTimeout;
@Column(name="ACCESS_TOKEN_LIFESPAN")
protected int accessTokenLifespan;
@Column(name="ACCESS_TOKEN_LIFE_IMPLICIT")
protected int accessTokenLifespanForImplicitFlow;
@Column(name="ACCESS_CODE_LIFESPAN")
protected int accessCodeLifespan;
@Column(name="USER_ACTION_LIFESPAN")
@ -336,6 +338,14 @@ public class RealmEntity {
this.accessTokenLifespan = accessTokenLifespan;
}
public int getAccessTokenLifespanForImplicitFlow() {
return accessTokenLifespanForImplicitFlow;
}
public void setAccessTokenLifespanForImplicitFlow(int accessTokenLifespanForImplicitFlow) {
this.accessTokenLifespanForImplicitFlow = accessTokenLifespanForImplicitFlow;
}
public int getAccessCodeLifespan() {
return accessCodeLifespan;
}

View file

@ -368,6 +368,17 @@ public class RealmAdapter extends AbstractMongoAdapter<MongoRealmEntity> impleme
updateRealm();
}
@Override
public int getAccessTokenLifespanForImplicitFlow() {
return realm.getAccessTokenLifespanForImplicitFlow();
}
@Override
public void setAccessTokenLifespanForImplicitFlow(int seconds) {
realm.setAccessTokenLifespanForImplicitFlow(seconds);
updateRealm();
}
@Override
public int getAccessCodeLifespan() {
return realm.getAccessCodeLifespan();

View file

@ -154,11 +154,10 @@ public class OIDCLoginProtocol implements LoginProtocol {
}
// Implicit or hybrid flow
if (responseType.hasResponseType(OIDCResponseType.TOKEN) || responseType.hasResponseType(OIDCResponseType.ID_TOKEN) || responseType.hasResponseType(OIDCResponseType.REFRESH_TOKEN)) {
if (responseType.isImplicitOrHybridFlow()) {
TokenManager tokenManager = new TokenManager();
AccessTokenResponse res = tokenManager.responseBuilder(realm, clientSession.getClient(), event, session, userSession, clientSession)
.generateAccessToken()
.generateRefreshToken()
.generateIDToken()
.build();
@ -173,12 +172,6 @@ public class OIDCLoginProtocol implements LoginProtocol {
redirectUri.addParam("expires_in", String.valueOf(res.getExpiresIn()));
}
// Not OIDC standard, but supported
if (responseType.hasResponseType(OIDCResponseType.REFRESH_TOKEN)) {
redirectUri.addParam("refresh_token", res.getRefreshToken());
redirectUri.addParam("refresh_expires_in", String.valueOf(res.getRefreshExpiresIn()));
}
redirectUri.addParam("not-before-policy", String.valueOf(res.getNotBeforePolicy()));
}

View file

@ -26,6 +26,7 @@ import org.keycloak.models.utils.KeycloakModelUtils;
import org.keycloak.protocol.ProtocolMapper;
import org.keycloak.protocol.oidc.mappers.OIDCAccessTokenMapper;
import org.keycloak.protocol.oidc.mappers.OIDCIDTokenMapper;
import org.keycloak.protocol.oidc.utils.OIDCResponseType;
import org.keycloak.protocol.oidc.utils.WebOriginsUtils;
import org.keycloak.representations.AccessToken;
import org.keycloak.representations.AccessTokenResponse;
@ -455,8 +456,9 @@ public class TokenManager {
if (session != null) {
token.setSessionState(session.getId());
}
if (realm.getAccessTokenLifespan() > 0) {
token.expiration(Time.currentTime() + realm.getAccessTokenLifespan());
int tokenLifespan = getTokenLifespan(realm, clientSession);
if (tokenLifespan > 0) {
token.expiration(Time.currentTime() + tokenLifespan);
}
Set<String> allowedOrigins = client.getWebOrigins();
if (allowedOrigins != null) {
@ -465,6 +467,15 @@ public class TokenManager {
return token;
}
private int getTokenLifespan(RealmModel realm, ClientSessionModel clientSession) {
boolean implicitFlow = false;
String responseType = clientSession.getNote(OIDCLoginProtocol.RESPONSE_TYPE_PARAM);
if (responseType != null) {
implicitFlow = OIDCResponseType.parse(responseType).isImplicitFlow();
}
return implicitFlow ? realm.getAccessTokenLifespanForImplicitFlow() : realm.getAccessTokenLifespan();
}
protected void addComposites(AccessToken token, RoleModel role) {
AccessToken.Access access = null;
if (role.getContainer() instanceof RealmModel) {
@ -582,9 +593,7 @@ public class TokenManager {
idToken.issuer(accessToken.getIssuer());
idToken.setNonce(accessToken.getNonce());
idToken.setSessionState(accessToken.getSessionState());
if (realm.getAccessTokenLifespan() > 0) {
idToken.expiration(Time.currentTime() + realm.getAccessTokenLifespan());
}
idToken.expiration(accessToken.getExpiration());
transformIDToken(session, idToken, realm, client, userSession.getUser(), userSession, clientSession);
return this;
}

View file

@ -172,8 +172,7 @@ public class AuthorizationEndpoint extends AuthorizationEndpointBase {
throw new ErrorPageException(session, Messages.STANDARD_FLOW_DISABLED);
}
if ((parsedResponseType.hasResponseType(OIDCResponseType.TOKEN) || parsedResponseType.hasResponseType(OIDCResponseType.ID_TOKEN) || parsedResponseType.hasResponseType(OIDCResponseType.REFRESH_TOKEN))
&& !client.isImplicitFlowEnabled()) {
if (parsedResponseType.isImplicitOrHybridFlow() && !client.isImplicitFlowEnabled()) {
event.error(Errors.NOT_ALLOWED);
throw new ErrorPageException(session, Messages.IMPLICIT_FLOW_DISABLED);
}

View file

@ -14,10 +14,9 @@ public class OIDCResponseType {
public static final String CODE = OIDCLoginProtocol.CODE_PARAM;
public static final String TOKEN = "token";
public static final String ID_TOKEN = "id_token";
public static final String REFRESH_TOKEN = "refresh_token"; // Not officially supported by OIDC
public static final String NONE = "none";
private static final List<String> ALLOWED_RESPONSE_TYPES = Arrays.asList(CODE, TOKEN, ID_TOKEN, REFRESH_TOKEN, NONE);
private static final List<String> ALLOWED_RESPONSE_TYPES = Arrays.asList(CODE, TOKEN, ID_TOKEN, NONE);
private final List<String> responseTypes;
@ -29,7 +28,7 @@ public class OIDCResponseType {
public static OIDCResponseType parse(String responseTypeParam) {
if (responseTypeParam == null) {
throw new IllegalStateException("response_type is null");
throw new IllegalArgumentException("response_type is null");
}
String[] responseTypes = responseTypeParam.trim().split(" ");
@ -38,12 +37,30 @@ public class OIDCResponseType {
if (ALLOWED_RESPONSE_TYPES.contains(current)) {
allowedTypes.add(current);
} else {
throw new IllegalStateException("Unsupported response_type: " + responseTypeParam);
throw new IllegalArgumentException("Unsupported response_type: " + responseTypeParam);
}
}
validateAllowedTypes(allowedTypes);
return new OIDCResponseType(allowedTypes);
}
private static void validateAllowedTypes(List<String> responseTypes) {
if (responseTypes.size() == 0) {
throw new IllegalStateException("No responseType provided");
}
if (responseTypes.contains(NONE) && responseTypes.size() > 1) {
throw new IllegalArgumentException("None not allowed with some other response_type");
}
if (responseTypes.contains(ID_TOKEN) && responseTypes.size() == 1) {
throw new IllegalArgumentException("Not supported to use response_type=id_token alone");
}
if (responseTypes.contains(TOKEN) && responseTypes.size() == 1) {
throw new IllegalArgumentException("Not supported to use response_type=token alone");
}
}
public boolean hasResponseType(String responseType) {
return responseTypes.contains(responseType);
@ -51,7 +68,11 @@ public class OIDCResponseType {
public boolean isImplicitOrHybridFlow() {
return hasResponseType(TOKEN) || hasResponseType(ID_TOKEN) || hasResponseType(REFRESH_TOKEN);
return hasResponseType(TOKEN) || hasResponseType(ID_TOKEN);
}
public boolean isImplicitFlow() {
return hasResponseType(TOKEN) && hasResponseType(ID_TOKEN) && !hasResponseType(CODE);
}

View file

@ -43,6 +43,7 @@ public class ApplianceBootstrap {
realm.addRequiredCredential(CredentialRepresentation.PASSWORD);
realm.setSsoSessionIdleTimeout(1800);
realm.setAccessTokenLifespan(60);
realm.setAccessTokenLifespanForImplicitFlow(Constants.DEFAULT_ACCESS_TOKEN_LIFESPAN_FOR_IMPLICIT_FLOW_TIMEOUT);
realm.setSsoSessionMaxLifespan(36000);
realm.setOfflineSessionIdleTimeout(Constants.DEFAULT_OFFLINE_SESSION_IDLE_TIMEOUT);
realm.setAccessCodeLifespan(60);

View file

@ -0,0 +1,41 @@
package org.keycloak.test;
import org.junit.Assert;
import org.junit.Test;
import org.keycloak.protocol.oidc.utils.OIDCResponseType;
/**
* @author <a href="mailto:mposolda@redhat.com">Marek Posolda</a>
*/
public class ResponseTypeTest {
@Test
public void testResponseTypes() {
assertFail(null);
assertFail("");
assertFail("foo");
assertSuccess("code");
assertSuccess("none");
assertFail("id_token");
assertFail("token");
assertFail("refresh_token");
assertSuccess("id_token token");
assertSuccess("code token");
assertSuccess("code id_token");
assertSuccess("code id_token token");
assertFail("code none");
assertFail("code refresh_token");
}
private void assertSuccess(String responseType) {
OIDCResponseType.parse(responseType);
}
private void assertFail(String responseType) {
try {
OIDCResponseType.parse(responseType);
Assert.fail("Not expected to parse '" + responseType + "' with success");
} catch (IllegalArgumentException expected) {
}
}
}

View file

@ -32,8 +32,14 @@ public class CreateClientForm extends Form {
@FindBy(xpath = ".//div[@class='onoffswitch' and ./input[@id='consentRequired']]")
private OnOffSwitch consentRequiredSwitch;
@FindBy(xpath = ".//div[@class='onoffswitch' and ./input[@id='directGrantsOnly']]")
private OnOffSwitch directGrantsOnlySwitch;
@FindBy(xpath = ".//div[@class='onoffswitch' and ./input[@id='standardFlowEnabled']]")
private OnOffSwitch standardFlowEnabledSwitch;
@FindBy(xpath = ".//div[@class='onoffswitch' and ./input[@id='implicitFlowEnabled']]")
private OnOffSwitch implicitFlowEnabledSwitch;
@FindBy(xpath = ".//div[@class='onoffswitch' and ./input[@id='directAccessGrantsEnabled']]")
private OnOffSwitch directAccessGrantsEnabledSwitch;
@FindBy(id = "protocol")
private Select protocolSelect;
@ -69,7 +75,9 @@ public class CreateClientForm extends Form {
setName(client.getName());
setEnabled(client.isEnabled());
setConsentRequired(client.isConsentRequired());
setDirectGrantsOnly(client.isDirectGrantsOnly());
setStandardFlowEnabled(client.isStandardFlowEnabled());
setImplicitFlowEnabled(client.isImplicitFlowEnabled());
setDirectAccessGrantsEnabled(client.isDirectAccessGrantsEnabled());
setProtocol(client.getProtocol());
if (OIDC.equals(client.getProtocol())) {
setAccessType(client);
@ -88,7 +96,9 @@ public class CreateClientForm extends Form {
values.setName(getName());
values.setEnabled(isEnabled());
values.setConsentRequired(isConsentRequired());
values.setDirectGrantsOnly(isDirectGrantsOnly());
values.setStandardFlowEnabled(isStandardFlowEnabled());
values.setImplicitFlowEnabled(isImplicitFlowEnabled());
values.setDirectAccessGrantsEnabled(isDirectAccessGrantsEnabled());
values.setProtocol(getProtocol());
if (OIDC.equals(values.getProtocol())) {
values.setBearerOnly(isBearerOnly());
@ -195,12 +205,28 @@ public class CreateClientForm extends Form {
consentRequiredSwitch.setOn(consentRequired);
}
public boolean isDirectGrantsOnly() {
return directGrantsOnlySwitch.isOn();
public boolean isStandardFlowEnabled() {
return standardFlowEnabledSwitch.isOn();
}
public void setDirectGrantsOnly(boolean directGrantsOnly) {
directGrantsOnlySwitch.setOn(directGrantsOnly);
public void setStandardFlowEnabled(boolean standardFlowEnabled) {
standardFlowEnabledSwitch.setOn(standardFlowEnabled);
}
public boolean isImplicitFlowEnabled() {
return implicitFlowEnabledSwitch.isOn();
}
public void setImplicitFlowEnabled(boolean implicitFlowEnabled) {
implicitFlowEnabledSwitch.setOn(implicitFlowEnabled);
}
public boolean isDirectAccessGrantsEnabled() {
return directAccessGrantsEnabledSwitch.isOn();
}
public void setDirectAccessGrantsEnabled(boolean directAccessGrantsEnabled) {
directAccessGrantsEnabledSwitch.setOn(directAccessGrantsEnabled);
}
public String getProtocol() {

View file

@ -56,7 +56,9 @@ public abstract class AbstractClientTest extends AbstractConsoleTest {
client.setClientId(clientId);
client.setEnabled(true);
client.setConsentRequired(false);
client.setDirectGrantsOnly(false);
client.setStandardFlowEnabled(true);
client.setImplicitFlowEnabled(false);
client.setDirectAccessGrantsEnabled(true);
client.setProtocol(OIDC);

View file

@ -111,7 +111,9 @@ public class ClientSettingsTest extends AbstractClientTest {
assertEqualsStringAttributes(c1.getName(), c2.getName());
assertEqualsBooleanAttributes(c1.isEnabled(), c2.isEnabled());
assertEqualsBooleanAttributes(c1.isConsentRequired(), c2.isConsentRequired());
assertEqualsBooleanAttributes(c1.isDirectGrantsOnly(), c2.isDirectGrantsOnly());
assertEqualsBooleanAttributes(c1.isStandardFlowEnabled(), c2.isStandardFlowEnabled());
assertEqualsBooleanAttributes(c1.isImplicitFlowEnabled(), c2.isImplicitFlowEnabled());
assertEqualsBooleanAttributes(c1.isDirectAccessGrantsEnabled(), c2.isDirectAccessGrantsEnabled());
assertEqualsStringAttributes(c1.getProtocol(), c2.getProtocol());
assertEqualsBooleanAttributes(c1.isBearerOnly(), c2.isBearerOnly());

View file

@ -55,7 +55,7 @@ public class LoginEventsTest extends AbstractConsoleTest {
List<WebElement> resultList = loginEventsPage.table().rows();
assertEquals(7, resultList.size());
assertEquals(8, resultList.size());
resultList.get(0).findElement(By.xpath("//td[text()='LOGIN']"));
resultList.get(0).findElement(By.xpath("//td[text()='User']/../td[text()='" + testUser.getId() + "']"));
resultList.get(0).findElement(By.xpath("//td[text()='Client']/../td[text()='security-admin-console']"));

View file

@ -255,6 +255,7 @@ public class AdminAPITest {
Assert.assertEquals(rep.getAccessCodeLifespanUserAction(), storedRealm.getAccessCodeLifespanUserAction());
if (rep.getNotBefore() != null) Assert.assertEquals(rep.getNotBefore(), storedRealm.getNotBefore());
if (rep.getAccessTokenLifespan() != null) Assert.assertEquals(rep.getAccessTokenLifespan(), storedRealm.getAccessTokenLifespan());
if (rep.getAccessTokenLifespanForImplicitFlow() != null) Assert.assertEquals(rep.getAccessTokenLifespanForImplicitFlow(), storedRealm.getAccessTokenLifespanForImplicitFlow());
if (rep.getSsoSessionIdleTimeout() != null) Assert.assertEquals(rep.getSsoSessionIdleTimeout(), storedRealm.getSsoSessionIdleTimeout());
if (rep.getSsoSessionMaxLifespan() != null) Assert.assertEquals(rep.getSsoSessionMaxLifespan(), storedRealm.getSsoSessionMaxLifespan());
if (rep.getRequiredCredentials() != null) {

View file

@ -74,6 +74,7 @@ public class ImportTest extends AbstractModelTest {
public static void assertDataImportedInRealm(KeycloakSession session, RealmModel realm) {
Assert.assertTrue(realm.isVerifyEmail());
Assert.assertEquals(3600000, realm.getOfflineSessionIdleTimeout());
Assert.assertEquals(1500, realm.getAccessTokenLifespanForImplicitFlow());
List<RequiredCredentialModel> creds = realm.getRequiredCredentials();
Assert.assertEquals(1, creds.size());
@ -351,6 +352,7 @@ public class ImportTest extends AbstractModelTest {
RealmModel realm =manager.importRealm(rep);
Assert.assertEquals(600, realm.getAccessCodeLifespanUserAction());
Assert.assertEquals(Constants.DEFAULT_ACCESS_TOKEN_LIFESPAN_FOR_IMPLICIT_FLOW_TIMEOUT, realm.getAccessTokenLifespanForImplicitFlow());
Assert.assertEquals(Constants.DEFAULT_OFFLINE_SESSION_IDLE_TIMEOUT, realm.getOfflineSessionIdleTimeout());
verifyRequiredCredentials(realm.getRequiredCredentials(), "password");
}

View file

@ -160,13 +160,22 @@ public class AuthorizationCodeTest {
assertCode(codeId, response.getCode());
}
@Test
public void authorizationRequestImplicitFlowDisabled() throws IOException {
UriBuilder b = UriBuilder.fromUri(oauth.getLoginFormUrl());
b.replaceQueryParam(OAuth2Constants.RESPONSE_TYPE, "token id_token");
driver.navigate().to(b.build().toURL());
assertEquals("Client is not allowed to initiate browser login with given response_type. Implicit flow is disabled for the client.", errorPage.getError());
events.expectLogin().error(Errors.NOT_ALLOWED).user((String) null).session((String) null).clearDetails().detail(Details.RESPONSE_TYPE, "token id_token").assertEvent();
}
@Test
public void authorizationRequestInvalidResponseType() throws IOException {
UriBuilder b = UriBuilder.fromUri(oauth.getLoginFormUrl());
b.replaceQueryParam(OAuth2Constants.RESPONSE_TYPE, "token");
driver.navigate().to(b.build().toURL());
assertEquals("Client is not allowed to initiate browser login with given response_type. Implicit flow is disabled for the client.", errorPage.getError());
events.expectLogin().error(Errors.NOT_ALLOWED).user((String) null).session((String) null).clearDetails().detail(Details.RESPONSE_TYPE, OIDCResponseType.TOKEN).assertEvent();
assertEquals("Invalid parameter: response_type", errorPage.getError());
events.expectLogin().error(Errors.INVALID_REQUEST).client((String) null).user((String) null).session((String) null).clearDetails().detail(Details.RESPONSE_TYPE, "token").assertEvent();
}
private void assertCode(String expectedCodeId, String actualCode) {

View file

@ -2,6 +2,7 @@
"realm": "test-realm",
"enabled": true,
"accessTokenLifespan": 6000,
"accessTokenLifespanForImplicitFlow": 1500,
"accessCodeLifespan": 30,
"accessCodeLifespanUserAction": 600,
"offlineSessionIdleTimeout": 3600000,