Verify holder of the device code (#21)
Closes https://github.com/keycloak/security/issues/32 Co-authored-by: Stian Thorgersen <stianst@gmail.com> Conflicts: services/src/main/java/org/keycloak/protocol/oidc/grants/device/DeviceGrantType.java
This commit is contained in:
parent
52186f0cc8
commit
28aa1d730d
5 changed files with 133 additions and 11 deletions
|
@ -193,6 +193,8 @@ public class OAuth2DeviceCodeModel {
|
|||
Map<String, String> result = new HashMap<>();
|
||||
|
||||
result.put(REALM_ID, realm.getId());
|
||||
result.put(CLIENT_ID, clientId);
|
||||
|
||||
if (clientNotificationToken != null) {
|
||||
result.put(CLIENT_NOTIFICATION_TOKEN_NOTE, clientNotificationToken);
|
||||
}
|
||||
|
@ -201,7 +203,6 @@ public class OAuth2DeviceCodeModel {
|
|||
}
|
||||
|
||||
if (denied == null) {
|
||||
result.put(CLIENT_ID, clientId);
|
||||
result.put(EXPIRATION_NOTE, String.valueOf(expiration));
|
||||
result.put(POLLING_INTERVAL_NOTE, String.valueOf(pollingInterval));
|
||||
result.put(SCOPE_NOTE, scope);
|
||||
|
|
|
@ -161,19 +161,13 @@ public class CibaGrantType {
|
|||
throw new CorsErrorResponseException(cors, OAuthErrorException.INVALID_GRANT, cpe.getErrorDetail(), Response.Status.BAD_REQUEST);
|
||||
}
|
||||
|
||||
OAuth2DeviceCodeModel deviceCode = DeviceGrantType.getDeviceByDeviceCode(session, realm, request.getId());
|
||||
OAuth2DeviceCodeModel deviceCode = DeviceGrantType.getDeviceByDeviceCode(session, realm, client, event, request.getId());
|
||||
|
||||
if (deviceCode == null) {
|
||||
// Auth Req ID has not put onto cache, no need to remove Auth Req ID.
|
||||
throw new CorsErrorResponseException(cors, OAuthErrorException.INVALID_GRANT, "Invalid " + AUTH_REQ_ID, Response.Status.BAD_REQUEST);
|
||||
}
|
||||
|
||||
if (!request.getIssuedFor().equals(client.getClientId())) {
|
||||
logDebug("invalid client.", request);
|
||||
// the client sending this Auth Req ID does not match the client to which keycloak had issued Auth Req ID.
|
||||
throw new CorsErrorResponseException(cors, OAuthErrorException.INVALID_GRANT, "unauthorized client", Response.Status.BAD_REQUEST);
|
||||
}
|
||||
|
||||
if (deviceCode.isExpired()) {
|
||||
logDebug("expired.", request);
|
||||
throw new CorsErrorResponseException(cors, OAuthErrorException.EXPIRED_TOKEN, "authentication timed out", Response.Status.BAD_REQUEST);
|
||||
|
|
|
@ -48,6 +48,7 @@ import org.keycloak.protocol.oidc.grants.device.clientpolicy.context.DeviceToken
|
|||
import org.keycloak.protocol.oidc.grants.device.endpoints.DeviceEndpoint;
|
||||
import org.keycloak.protocol.oidc.utils.PkceUtils;
|
||||
import org.keycloak.services.CorsErrorResponseException;
|
||||
import org.keycloak.services.ErrorResponseException;
|
||||
import org.keycloak.services.clientpolicy.ClientPolicyException;
|
||||
import org.keycloak.services.managers.AuthenticationManager;
|
||||
import org.keycloak.services.managers.UserSessionCrossDCManager;
|
||||
|
@ -145,10 +146,20 @@ public class DeviceGrantType {
|
|||
return flow != null;
|
||||
}
|
||||
|
||||
public static OAuth2DeviceCodeModel getDeviceByDeviceCode(KeycloakSession session, RealmModel realm, String deviceCode) {
|
||||
public static OAuth2DeviceCodeModel getDeviceByDeviceCode(KeycloakSession session, RealmModel realm, ClientModel client, EventBuilder event, String deviceCode) {
|
||||
SingleUseObjectProvider singleUseStore = session.singleUseObjects();
|
||||
Map<String, String> notes = singleUseStore.get(OAuth2DeviceCodeModel.createKey(deviceCode));
|
||||
return notes != null ? OAuth2DeviceCodeModel.fromCache(realm, deviceCode, notes) : null;
|
||||
OAuth2DeviceCodeModel deviceCodeModel = notes != null ? OAuth2DeviceCodeModel.fromCache(realm, deviceCode, notes) : null;
|
||||
|
||||
if (deviceCodeModel != null) {
|
||||
if (!client.getClientId().equals(deviceCodeModel.getClientId())) {
|
||||
event.error(Errors.INVALID_OAUTH2_DEVICE_CODE);
|
||||
throw new ErrorResponseException(OAuthErrorException.INVALID_GRANT, "unauthorized client",
|
||||
Response.Status.BAD_REQUEST);
|
||||
}
|
||||
}
|
||||
|
||||
return deviceCodeModel;
|
||||
}
|
||||
|
||||
public static void removeDeviceByDeviceCode(KeycloakSession session, String deviceCode) {
|
||||
|
@ -227,7 +238,7 @@ public class DeviceGrantType {
|
|||
"Missing parameter: " + OAuth2Constants.DEVICE_CODE, Response.Status.BAD_REQUEST);
|
||||
}
|
||||
|
||||
OAuth2DeviceCodeModel deviceCodeModel = getDeviceByDeviceCode(session, realm, deviceCode);
|
||||
OAuth2DeviceCodeModel deviceCodeModel = getDeviceByDeviceCode(session, realm, client, event, deviceCode);
|
||||
|
||||
if (deviceCodeModel == null) {
|
||||
event.error(Errors.INVALID_OAUTH2_DEVICE_CODE);
|
||||
|
|
|
@ -1104,6 +1104,52 @@ public class CIBATest extends AbstractClientPoliciesTest {
|
|||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testVerifyHolderOfAuthenticationRequestRef() throws Exception {
|
||||
ClientResource firstClientResource = null;
|
||||
ClientResource secondClientResource = null;
|
||||
ClientRepresentation firstClientRep = null;
|
||||
ClientRepresentation secondClientRep = null;
|
||||
try {
|
||||
final String username = "nutzername-gelb";
|
||||
final String firstClientName = "test-app-scope"; // see testrealm.json
|
||||
final String secondClientName = TEST_CLIENT_NAME;
|
||||
final String firstClientPassword = "password";
|
||||
final String secondClientPassword = TEST_CLIENT_PASSWORD;
|
||||
String firstClientAuthReqId = null;
|
||||
|
||||
firstClientResource = ApiUtil.findClientByClientId(adminClient.realm(TEST_REALM_NAME), firstClientName);
|
||||
assertThat(firstClientResource, notNullValue());
|
||||
|
||||
firstClientRep = firstClientResource.toRepresentation();
|
||||
prepareCIBASettings(firstClientResource, firstClientRep);
|
||||
|
||||
secondClientResource = ApiUtil.findClientByClientId(adminClient.realm(TEST_REALM_NAME), secondClientName);
|
||||
assertThat(secondClientResource, notNullValue());
|
||||
|
||||
secondClientRep = secondClientResource.toRepresentation();
|
||||
prepareCIBASettings(secondClientResource, secondClientRep);
|
||||
|
||||
// first client Backchannel Authentication Request
|
||||
AuthenticationRequestAcknowledgement response = doBackchannelAuthenticationRequest(firstClientName, firstClientPassword, username, "asdfghjkl");
|
||||
firstClientAuthReqId = response.getAuthReqId();
|
||||
|
||||
// first client Authentication Channel Request
|
||||
TestAuthenticationChannelRequest firstClientAuthenticationChannelReq = doAuthenticationChannelRequest("asdfghjkl");
|
||||
|
||||
// first client Authentication Channel completed
|
||||
doAuthenticationChannelCallback(firstClientAuthenticationChannelReq);
|
||||
|
||||
// second client Token Request
|
||||
OAuthClient.AccessTokenResponse tokenRes = oauth.doBackchannelAuthenticationTokenRequest(secondClientName, secondClientPassword, firstClientAuthReqId);
|
||||
assertEquals(400, tokenRes.getStatusCode());
|
||||
assertEquals("unauthorized client", tokenRes.getErrorDescription());
|
||||
} finally {
|
||||
revertCIBASettings(firstClientResource, firstClientRep);
|
||||
revertCIBASettings(secondClientResource, secondClientRep);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testRequestTokenBeforeAuthenticationNotCompleted() throws Exception {
|
||||
ClientResource clientResource = null;
|
||||
|
|
|
@ -16,6 +16,7 @@
|
|||
*/
|
||||
package org.keycloak.testsuite.oauth;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertNotNull;
|
||||
import static org.junit.Assert.assertNull;
|
||||
import static org.keycloak.models.OAuth2DeviceConfig.DEFAULT_OAUTH2_DEVICE_CODE_LIFESPAN;
|
||||
|
@ -59,11 +60,13 @@ import org.apache.http.impl.client.HttpClientBuilder;
|
|||
import org.apache.http.message.BasicNameValuePair;
|
||||
import org.apache.http.impl.client.CloseableHttpClient;
|
||||
import org.keycloak.util.BasicAuthHelper;
|
||||
import org.openqa.selenium.Cookie;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.LinkedList;
|
||||
|
||||
import java.io.UnsupportedEncodingException;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
/**
|
||||
* @author <a href="mailto:h2-wada@nri.co.jp">Hiroyuki Wada</a>
|
||||
|
@ -106,6 +109,7 @@ public class OAuth2DeviceAuthorizationGrantTest extends AbstractKeycloakTest {
|
|||
|
||||
ClientRepresentation appPublic = ClientBuilder.create().id(KeycloakModelUtils.generateId()).publicClient()
|
||||
.clientId(DEVICE_APP_PUBLIC).attribute(OAuth2DeviceConfig.OAUTH2_DEVICE_AUTHORIZATION_GRANT_ENABLED, "true")
|
||||
.redirectUris(OAuthClient.APP_ROOT + "/auth")
|
||||
.build();
|
||||
realm.client(appPublic);
|
||||
|
||||
|
@ -243,6 +247,72 @@ public class OAuth2DeviceAuthorizationGrantTest extends AbstractKeycloakTest {
|
|||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testVerifyHolderOfDeviceCode() throws Exception {
|
||||
// Device Authorization Request from device
|
||||
oauth.realm(REALM_NAME);
|
||||
oauth.clientId(DEVICE_APP_PUBLIC);
|
||||
OAuthClient.DeviceAuthorizationResponse response = oauth.doDeviceAuthorizationRequest(DEVICE_APP_PUBLIC, null);
|
||||
|
||||
assertEquals(200, response.getStatusCode());
|
||||
assertNotNull(response.getDeviceCode());
|
||||
assertNotNull(response.getUserCode());
|
||||
assertNotNull(response.getVerificationUri());
|
||||
assertNotNull(response.getVerificationUriComplete());
|
||||
assertEquals(60, response.getExpiresIn());
|
||||
assertEquals(5, response.getInterval());
|
||||
|
||||
openVerificationPage(response.getVerificationUriComplete());
|
||||
|
||||
// Do Login
|
||||
oauth.fillLoginForm("device-login", "password");
|
||||
|
||||
// Consent
|
||||
grantPage.accept();
|
||||
|
||||
// Token request from device
|
||||
OAuthClient.AccessTokenResponse tokenResponse = oauth.doDeviceTokenRequest(DEVICE_APP_PUBLIC, null, response.getDeviceCode());
|
||||
|
||||
assertEquals(200, tokenResponse.getStatusCode());
|
||||
|
||||
String tokenString = tokenResponse.getAccessToken();
|
||||
assertNotNull(tokenString);
|
||||
AccessToken token = oauth.verifyToken(tokenString);
|
||||
|
||||
assertNotNull(token);
|
||||
|
||||
for (Cookie cookie : driver.manage().getCookies()) {
|
||||
driver.manage().deleteCookie(cookie);
|
||||
}
|
||||
|
||||
oauth.openLoginForm();
|
||||
|
||||
oauth.realm(REALM_NAME);
|
||||
oauth.clientId(DEVICE_APP_PUBLIC_CUSTOM_CONSENT);
|
||||
|
||||
oauth.fillLoginForm("device-login", "password");
|
||||
|
||||
for (Cookie cookie : driver.manage().getCookies()) {
|
||||
driver.manage().deleteCookie(cookie);
|
||||
}
|
||||
|
||||
oauth.openLoginForm();
|
||||
|
||||
response = oauth.doDeviceAuthorizationRequest(DEVICE_APP_PUBLIC_CUSTOM_CONSENT, null);
|
||||
|
||||
openVerificationPage(response.getVerificationUriComplete());
|
||||
|
||||
// Consent
|
||||
Assert.assertTrue(grantPage.getDisplayedGrants().contains("This is the custom consent screen text."));
|
||||
grantPage.accept();
|
||||
|
||||
// Token request from device
|
||||
tokenResponse = oauth.doDeviceTokenRequest(DEVICE_APP_PUBLIC, null, response.getDeviceCode());
|
||||
|
||||
assertEquals(400, tokenResponse.getStatusCode());
|
||||
assertEquals("unauthorized client", tokenResponse.getErrorDescription());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testPublicClientOptionalScope() throws Exception {
|
||||
// Device Authorization Request from device - check giving optional scope phone
|
||||
|
|
Loading…
Reference in a new issue