From bc1146ac2f0d3c6d96541908d315095f4757ab6a Mon Sep 17 00:00:00 2001 From: mposolda Date: Tue, 18 Feb 2020 18:09:40 +0100 Subject: [PATCH] KEYCLOAK-10029 Offline token migration fix. Always test offline-token migration when run MigrationTest --- .../keycloak/protocol/oidc/TokenManager.java | 1 + .../integration-arquillian/HOW-TO-RUN.md | 2 +- .../migration/MigrationContext.java | 14 +++--- .../migration/AbstractMigrationTest.java | 46 ++++++++++++++----- .../JsonFileImport198MigrationTest.java | 1 - .../testsuite/migration/MigrationTest.java | 18 ++++++-- .../migration-realm-1.9.8.Final.json | 29 +++++++++++- .../migration-realm-2.5.5.Final.json | 33 +++++++++++-- .../migration-realm-3.4.3.Final.json | 34 ++++++++++++-- .../migration-realm-4.8.3.Final.json | 34 ++++++++++++-- 10 files changed, 178 insertions(+), 34 deletions(-) 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 dfdfcf848a..aed55c2e5c 100755 --- a/services/src/main/java/org/keycloak/protocol/oidc/TokenManager.java +++ b/services/src/main/java/org/keycloak/protocol/oidc/TokenManager.java @@ -179,6 +179,7 @@ public class TokenManager { if (oldTokenScope == null && userSession.isOffline()) { logger.debugf("Migrating offline token of user '%s' for client '%s' of realm '%s'", user.getUsername(), client.getClientId(), realm.getName()); MigrationUtils.migrateOldOfflineToken(session, realm, client, user); + oldTokenScope = OAuth2Constants.OFFLINE_ACCESS; } ClientSessionContext clientSessionCtx = DefaultClientSessionContext.fromClientSessionAndScopeParameter(clientSession, oldTokenScope, session); diff --git a/testsuite/integration-arquillian/HOW-TO-RUN.md b/testsuite/integration-arquillian/HOW-TO-RUN.md index 6aea089542..6a9dabbd76 100644 --- a/testsuite/integration-arquillian/HOW-TO-RUN.md +++ b/testsuite/integration-arquillian/HOW-TO-RUN.md @@ -44,7 +44,7 @@ and adapter are all in the same JVM and you can debug them easily. If it is not Or slightly longer version (that allows you to specify debugging port as well as wait till you attach the debugger): - -Dmaven.surefire.debug="-Xdebug -Xrunjdwp:transport=dt_socket,server=y,suspend=n,address=5006 -Xnoagent -Djava.compiler=NONE" + -Dmaven.surefire.debug="-Xdebug -Xrunjdwp:transport=dt_socket,server=y,suspend=y,address=5006 -Xnoagent -Djava.compiler=NONE" and you will be able to attach remote debugger to the test. Unfortunately server and adapter are running in different JVMs, so this won't help to debug those. diff --git a/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/arquillian/migration/MigrationContext.java b/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/arquillian/migration/MigrationContext.java index 623a533442..b2c1e460bc 100644 --- a/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/arquillian/migration/MigrationContext.java +++ b/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/arquillian/migration/MigrationContext.java @@ -43,7 +43,7 @@ public class MigrationContext { try (FileInputStream fis = new FileInputStream(file)) { String offlineToken = StreamUtil.readString(fis, Charset.forName("UTF-8")); - + logger.infof("Successfully read offline token: %s", offlineToken); File f = new File(file); f.delete(); logger.infof("Deleted file with offline token: %s", file); @@ -67,7 +67,7 @@ public class MigrationContext { oauth.scope(OAuth2Constants.OFFLINE_ACCESS); oauth.realm("Migration"); oauth.clientId("migration-test-client"); - OAuthClient.AccessTokenResponse tokenResponse = oauth.doGrantAccessTokenRequest("b2c07929-69e3-44c6-8d7f-76939000b3e4", "migration-test-user", "admin"); + OAuthClient.AccessTokenResponse tokenResponse = oauth.doGrantAccessTokenRequest("secret", "offline-test-user", "password2"); return tokenResponse.getRefreshToken(); } catch (Exception e) { throw new RuntimeException(e); @@ -77,7 +77,7 @@ public class MigrationContext { private void saveOfflineToken(String offlineToken) throws Exception { String file = getOfflineTokenLocation(); - logger.infof("Saving offline token to file: %s", file); + logger.infof("Saving offline token to file: %s, Offline token is: %s", file, offlineToken); try (PrintWriter writer = new PrintWriter(new BufferedWriter(new FileWriter(file)))) { writer.print(offlineToken); @@ -85,10 +85,12 @@ public class MigrationContext { } - // Needs to save offline token inside "basedir". There are issues with saving into directory "target" as it's cleared among restarts and - // using "mvn install" instead of "mvn clean install" doesn't work ATM. Improve if needed... private String getOfflineTokenLocation() { - return System.getProperty("basedir") + "/offline-token.txt"; + String tmpDir = System.getProperty("java.io.tmpdir", ""); + if (tmpDir == null) { + tmpDir = System.getProperty("basedir"); + } + return tmpDir + "/offline-token.txt"; } } \ No newline at end of file diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/migration/AbstractMigrationTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/migration/AbstractMigrationTest.java index cc9e34a27f..17d65c3d9f 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/migration/AbstractMigrationTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/migration/AbstractMigrationTest.java @@ -18,6 +18,7 @@ package org.keycloak.testsuite.migration; import org.apache.http.impl.client.CloseableHttpClient; import org.apache.http.impl.client.HttpClientBuilder; +import org.keycloak.OAuth2Constants; import org.keycloak.admin.client.resource.ClientResource; import org.keycloak.admin.client.resource.ClientsResource; import org.keycloak.admin.client.resource.RealmResource; @@ -44,6 +45,7 @@ import org.keycloak.models.utils.TimeBasedOTP; import org.keycloak.protocol.oidc.OIDCConfigAttributes; import org.keycloak.protocol.oidc.OIDCLoginProtocolFactory; import org.keycloak.representations.AccessToken; +import org.keycloak.representations.RefreshToken; import org.keycloak.representations.idm.AuthenticationExecutionExportRepresentation; import org.keycloak.representations.idm.AuthenticationExecutionInfoRepresentation; import org.keycloak.representations.idm.AuthenticationFlowRepresentation; @@ -62,9 +64,12 @@ import org.keycloak.storage.UserStorageProvider; import org.keycloak.testsuite.AbstractKeycloakTest; import org.keycloak.testsuite.Assert; import org.keycloak.testsuite.admin.ApiUtil; +import org.keycloak.testsuite.arquillian.migration.MigrationContext; import org.keycloak.testsuite.exportimport.ExportImportUtil; import org.keycloak.testsuite.runonserver.RunHelpers; import org.keycloak.testsuite.util.OAuthClient; +import org.keycloak.testsuite.util.WaitUtils; +import org.keycloak.util.TokenUtil; import java.io.IOException; import java.net.URI; @@ -126,7 +131,7 @@ public abstract class AbstractMigrationTest extends AbstractKeycloakTest { assertNames(migrationRealm.clients().findAll(), expectedClientIds.toArray(new String[expectedClientIds.size()])); String id2 = migrationRealm.clients().findByClientId("migration-test-client").get(0).getId(); assertNames(migrationRealm.clients().get(id2).roles().list(), "migration-test-client-role"); - assertNames(migrationRealm.users().search("", 0, 5), "migration-test-user"); + assertNames(migrationRealm.users().search("", 0, 5), "migration-test-user", "offline-test-user"); assertNames(migrationRealm.groups().groups(), "migration-test-group"); } @@ -180,10 +185,6 @@ public abstract class AbstractMigrationTest extends AbstractKeycloakTest { testDuplicateEmailSupport(masterRealm, migrationRealm); } - protected void testMigrationTo2_5_1() throws Exception { - testOfflineTokenLogin(); - } - /** * @see org.keycloak.migration.migrators.MigrateTo3_0_0 */ @@ -649,9 +650,32 @@ public abstract class AbstractMigrationTest extends AbstractKeycloakTest { oauth.realm(MIGRATION); oauth.clientId("migration-test-client"); - OAuthClient.AccessTokenResponse response = oauth.doRefreshTokenRequest(oldOfflineToken, "b2c07929-69e3-44c6-8d7f-76939000b3e4"); + OAuthClient.AccessTokenResponse response = oauth.doRefreshTokenRequest(oldOfflineToken, "secret"); + + if (response.getError() != null) { + String errorMessage = String.format("Error when refreshing offline token. Error: %s, Error details: %s, offline token from previous version: %s", + response.getError(), response.getErrorDescription(), oldOfflineToken); + log.error(errorMessage); + Assert.fail(errorMessage); + } + AccessToken accessToken = oauth.verifyToken(response.getAccessToken()); - assertEquals("migration-test-user", accessToken.getPreferredUsername()); + assertEquals("offline-test-user", accessToken.getPreferredUsername()); + + // KEYCLOAK-10029 - Doublecheck that refresh token in the response is also offline token. Doublecheck that it can be used to another successful refresh + String newOfflineToken1 = response.getRefreshToken(); + assertOfflineToken(newOfflineToken1); + + response = oauth.doRefreshTokenRequest(newOfflineToken1, "secret"); + String newOfflineToken2 = response.getRefreshToken(); + assertOfflineToken(newOfflineToken2); + } + + private void assertOfflineToken(String offlineToken) { + RefreshToken offlineTokenParsed = oauth.parseRefreshToken(offlineToken); + assertEquals(TokenUtil.TOKEN_TYPE_OFFLINE, offlineTokenParsed.getType()); + assertEquals(0, offlineTokenParsed.getExpiration()); + assertTrue(TokenUtil.hasScope(offlineTokenParsed.getScope(), OAuth2Constants.OFFLINE_ACCESS)); } private void testRealmDefaultClientScopes(RealmResource realm) { @@ -748,19 +772,19 @@ public abstract class AbstractMigrationTest extends AbstractKeycloakTest { String otp = otpGenerator.generateTOTP("dSdmuHLQhkm54oIm0A0S"); // Try invalid password first - OAuthClient.AccessTokenResponse response = oauth.doGrantAccessTokenRequest("b2c07929-69e3-44c6-8d7f-76939000b3e4", + OAuthClient.AccessTokenResponse response = oauth.doGrantAccessTokenRequest("secret", "migration-test-user", "password", otp); Assert.assertNull(response.getAccessToken()); Assert.assertNotNull(response.getError()); // Try invalid OTP then - response = oauth.doGrantAccessTokenRequest("b2c07929-69e3-44c6-8d7f-76939000b3e4", + response = oauth.doGrantAccessTokenRequest("secret", "migration-test-user", "password2", "invalid"); Assert.assertNull(response.getAccessToken()); Assert.assertNotNull(response.getError()); // Try successful login now - response = oauth.doGrantAccessTokenRequest("b2c07929-69e3-44c6-8d7f-76939000b3e4", + response = oauth.doGrantAccessTokenRequest("secret", "migration-test-user", "password2", otp); Assert.assertNull(response.getError()); AccessToken accessToken = oauth.verifyToken(response.getAccessToken()); @@ -770,7 +794,6 @@ public abstract class AbstractMigrationTest extends AbstractKeycloakTest { } } - protected void testOTPAuthenticatorsMigratedToConditionalFlow() { log.info("testing optional authentication executions migrated"); @@ -831,7 +854,6 @@ public abstract class AbstractMigrationTest extends AbstractKeycloakTest { testMigrationTo2_2_0(); testMigrationTo2_3_0(); testMigrationTo2_5_0(); - testMigrationTo2_5_1(); } protected void testMigrationTo3_x() { diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/migration/JsonFileImport198MigrationTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/migration/JsonFileImport198MigrationTest.java index dc34f89be0..644d48b5d2 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/migration/JsonFileImport198MigrationTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/migration/JsonFileImport198MigrationTest.java @@ -63,7 +63,6 @@ public class JsonFileImport198MigrationTest extends AbstractJsonFileImportMigrat testMigrationTo2_2_0(); testMigrationTo2_3_0(); testMigrationTo2_5_0(); - //testMigrationTo2_5_1(); // Offline tokens migration is skipped for JSON testMigrationTo3_x(); testMigrationTo4_x(false, false); testMigrationTo5_x(); diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/migration/MigrationTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/migration/MigrationTest.java index cef84fdc58..b4a22eba29 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/migration/MigrationTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/migration/MigrationTest.java @@ -59,18 +59,21 @@ public class MigrationTest extends AbstractMigrationTest { @Test @Migration(versionFrom = "4.") - public void migration4_xTest() { + public void migration4_xTest() throws Exception { testMigratedData(); testMigrationTo5_x(); testMigrationTo6_x(); testMigrationTo7_x(true); testMigrationTo8_x(); testMigrationTo9_x(); + + // Always test offline-token login during migration test + testOfflineTokenLogin(); } @Test @Migration(versionFrom = "3.") - public void migration3_xTest() { + public void migration3_xTest() throws Exception { testMigratedData(); testMigrationTo4_x(); testMigrationTo5_x(); @@ -78,11 +81,14 @@ public class MigrationTest extends AbstractMigrationTest { testMigrationTo7_x(true); testMigrationTo8_x(); testMigrationTo9_x(); + + // Always test offline-token login during migration test + testOfflineTokenLogin(); } @Test @Migration(versionFrom = "2.") - public void migration2_xTest() { + public void migration2_xTest() throws Exception { testMigratedData(); testMigrationTo3_x(); testMigrationTo4_x(); @@ -91,6 +97,9 @@ public class MigrationTest extends AbstractMigrationTest { testMigrationTo7_x(true); testMigrationTo8_x(); testMigrationTo9_x(); + + // Always test offline-token login during migration test + testOfflineTokenLogin(); } @Test @@ -105,6 +114,9 @@ public class MigrationTest extends AbstractMigrationTest { testMigrationTo7_x(false); testMigrationTo8_x(); testMigrationTo9_x(); + + // Always test offline-token login during migration test + testOfflineTokenLogin(); } } diff --git a/testsuite/integration-arquillian/tests/base/src/test/resources/migration-test/migration-realm-1.9.8.Final.json b/testsuite/integration-arquillian/tests/base/src/test/resources/migration-test/migration-realm-1.9.8.Final.json index 5bc60746e4..c1ec76f2c6 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/resources/migration-test/migration-realm-1.9.8.Final.json +++ b/testsuite/integration-arquillian/tests/base/src/test/resources/migration-test/migration-realm-1.9.8.Final.json @@ -1769,7 +1769,32 @@ "account" : [ "manage-account", "view-profile" ] }, "groups" : [ "/migration-test-group" ] - } ], + }, + { + "id" : "9aa0d4f7-399e-4520-92df-77403d5d2a33", + "createdTimestamp" : 1476260593350, + "username" : "offline-test-user", + "enabled" : true, + "totp" : false, + "emailVerified" : false, + "credentials" : [ { + "type" : "password", + "hashedSaltedValue" : "D3F6cEj0pNv1UvkPq2XhnH5TTg2BaR2qKQd+vMoT8Pj+cHEGvISbBujjD9+889LIhWUSbQS8nkZH0yEnrTKBAA==", + "salt" : "C2vKhAsajS53Xu816IcKIw==", + "hashIterations" : 20000, + "counter" : 0, + "algorithm" : "pbkdf2", + "digits" : 0, + "period" : 0, + "createdDate" : 1582099686822 + } ], + "requiredActions" : [ ], + "realmRoles" : [ "offline_access" ], + "clientRoles" : { + "account" : [ "manage-account", "view-profile" ] + }, + "groups" : [ ] + } ], "clientScopeMappings" : { "realm-management" : [ { "client" : "admin-cli", @@ -2100,7 +2125,7 @@ "surrogateAuthRequired" : false, "enabled" : true, "clientAuthenticatorType" : "client-secret", - "secret" : "b2c07929-69e3-44c6-8d7f-76939000b3e4", + "secret" : "secret", "redirectUris" : [ ], "webOrigins" : [ ], "notBefore" : 0, diff --git a/testsuite/integration-arquillian/tests/base/src/test/resources/migration-test/migration-realm-2.5.5.Final.json b/testsuite/integration-arquillian/tests/base/src/test/resources/migration-test/migration-realm-2.5.5.Final.json index f6b4d00ab6..b32cc2b8ab 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/resources/migration-test/migration-realm-2.5.5.Final.json +++ b/testsuite/integration-arquillian/tests/base/src/test/resources/migration-test/migration-realm-2.5.5.Final.json @@ -2121,7 +2121,34 @@ "account" : [ "view-profile", "manage-account" ] }, "groups" : [ ] - } ], + }, + { + "id" : "556eb430-d574-4956-908a-83527a77932a", + "createdTimestamp" : 1489756947105, + "username" : "offline-test-user", + "enabled" : true, + "totp" : false, + "emailVerified" : false, + "credentials" : [ { + "type" : "password", + "hashedSaltedValue" : "D3F6cEj0pNv1UvkPq2XhnH5TTg2BaR2qKQd+vMoT8Pj+cHEGvISbBujjD9+889LIhWUSbQS8nkZH0yEnrTKBAA==", + "salt" : "C2vKhAsajS53Xu816IcKIw==", + "hashIterations" : 20000, + "counter" : 0, + "algorithm" : "pbkdf2", + "digits" : 0, + "period" : 0, + "createdDate" : 1582099686822, + "config" : { } + } ], + "disableableCredentialTypes" : [ "password" ], + "requiredActions" : [ ], + "realmRoles" : [ "uma_authorization", "offline_access" ], + "clientRoles" : { + "account" : [ "view-profile", "manage-account" ] + }, + "groups" : [ ] + } ], "clientScopeMappings" : { "realm-management" : [ { "client" : "admin-cli", @@ -2471,7 +2498,7 @@ "surrogateAuthRequired" : false, "enabled" : true, "clientAuthenticatorType" : "client-secret", - "secret" : "75da9358-22e0-4ab5-9609-5c74c40dd70f", + "secret" : "secret", "redirectUris" : [ ], "webOrigins" : [ ], "notBefore" : 0, @@ -2481,7 +2508,7 @@ "implicitFlowEnabled" : false, "directAccessGrantsEnabled" : true, "serviceAccountsEnabled" : false, - "publicClient" : true, + "publicClient" : false, "frontchannelLogout" : false, "protocol" : "openid-connect", "attributes" : { }, diff --git a/testsuite/integration-arquillian/tests/base/src/test/resources/migration-test/migration-realm-3.4.3.Final.json b/testsuite/integration-arquillian/tests/base/src/test/resources/migration-test/migration-realm-3.4.3.Final.json index fdd2a77074..f19e9994af 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/resources/migration-test/migration-realm-3.4.3.Final.json +++ b/testsuite/integration-arquillian/tests/base/src/test/resources/migration-test/migration-realm-3.4.3.Final.json @@ -333,7 +333,35 @@ }, "notBefore" : 0, "groups" : [ ] - } ], + }, + { + "id" : "3a15a4f3-0e14-4b57-8753-2d774ef02fce", + "createdTimestamp" : 1531933208712, + "username" : "offline-test-user", + "enabled" : true, + "totp" : false, + "emailVerified" : false, + "credentials" : [ { + "type" : "password", + "hashedSaltedValue" : "kNwotFPNeuwelpT1HWt+E4ONXFK6wjd+h0zbzNBRGwOqacAjeY7vYN9QZQ46DlEKSdn04cEU/3RvX8WPcRegxg==", + "salt" : "rEIJDbs+BQqpx31v8mONWA==", + "hashIterations" : 27500, + "counter" : 0, + "algorithm" : "pbkdf2-sha256", + "digits" : 0, + "period" : 0, + "createdDate" : 1570002786025, + "config" : { } + } ], + "disableableCredentialTypes" : [ ], + "requiredActions" : [ ], + "realmRoles" : [ "offline_access", "uma_authorization" ], + "clientRoles" : { + "account" : [ "manage-account", "view-profile" ] + }, + "notBefore" : 0, + "groups" : [ ] + } ], "clientScopeMappings" : { "migration-test-client": [ { @@ -679,7 +707,7 @@ "surrogateAuthRequired" : false, "enabled" : true, "clientAuthenticatorType" : "client-secret", - "secret" : "d926c1c0-056a-4418-86d5-f103112dec43", + "secret" : "secret", "redirectUris" : [ ], "webOrigins" : [ ], "notBefore" : 0, @@ -689,7 +717,7 @@ "implicitFlowEnabled" : false, "directAccessGrantsEnabled" : true, "serviceAccountsEnabled" : false, - "publicClient" : true, + "publicClient" : false, "frontchannelLogout" : false, "protocol" : "openid-connect", "attributes" : { }, diff --git a/testsuite/integration-arquillian/tests/base/src/test/resources/migration-test/migration-realm-4.8.3.Final.json b/testsuite/integration-arquillian/tests/base/src/test/resources/migration-test/migration-realm-4.8.3.Final.json index 36e7c758ef..3c42e180d4 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/resources/migration-test/migration-realm-4.8.3.Final.json +++ b/testsuite/integration-arquillian/tests/base/src/test/resources/migration-test/migration-realm-4.8.3.Final.json @@ -338,7 +338,35 @@ }, "notBefore" : 0, "groups" : [ ] - } ], + }, + { + "id" : "189110e3-0b38-4ae3-b019-dce1f1b34512", + "createdTimestamp" : 1550760939539, + "username" : "offline-test-user", + "enabled" : true, + "totp" : false, + "emailVerified" : false, + "credentials" : [ { + "type" : "password", + "hashedSaltedValue" : "kNwotFPNeuwelpT1HWt+E4ONXFK6wjd+h0zbzNBRGwOqacAjeY7vYN9QZQ46DlEKSdn04cEU/3RvX8WPcRegxg==", + "salt" : "rEIJDbs+BQqpx31v8mONWA==", + "hashIterations" : 27500, + "counter" : 0, + "algorithm" : "pbkdf2-sha256", + "digits" : 0, + "period" : 0, + "createdDate" : 1570002786025, + "config" : { } + } ], + "disableableCredentialTypes" : [ ], + "requiredActions" : [ ], + "realmRoles" : [ "uma_authorization", "offline_access" ], + "clientRoles" : { + "account" : [ "view-profile", "manage-account" ] + }, + "notBefore" : 0, + "groups" : [ ] + } ], "scopeMappings" : [ { "clientScope" : "offline_access", "roles" : [ "offline_access" ] @@ -480,7 +508,7 @@ "surrogateAuthRequired" : false, "enabled" : true, "clientAuthenticatorType" : "client-secret", - "secret" : "ce99063e-6d4e-4342-b4ec-62a54a46e9dd", + "secret" : "secret", "redirectUris" : [ ], "webOrigins" : [ ], "notBefore" : 0, @@ -490,7 +518,7 @@ "implicitFlowEnabled" : false, "directAccessGrantsEnabled" : true, "serviceAccountsEnabled" : false, - "publicClient" : true, + "publicClient" : false, "frontchannelLogout" : false, "protocol" : "openid-connect", "attributes" : { },