Skip to content

Commit

Permalink
Verify holder of the device code (#21)
Browse files Browse the repository at this point in the history
Closes keycloak/security#32

Co-authored-by: Stian Thorgersen <[email protected]>
Conflicts:
    services/src/main/java/org/keycloak/protocol/oidc/grants/device/DeviceGrantType.java
  • Loading branch information
pedroigor authored and mposolda committed Jun 28, 2023
1 parent 52186f0 commit 28aa1d7
Show file tree
Hide file tree
Showing 5 changed files with 133 additions and 11 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,8 @@ public Map<String, String> toMap() {
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);
}
Expand All @@ -201,7 +203,6 @@ public Map<String, String> toMap() {
}

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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -161,19 +161,13 @@ public Response cibaGrant() {
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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@
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;
Expand Down Expand Up @@ -145,10 +146,20 @@ public static boolean isOAuth2DeviceVerificationFlow(final AuthenticationSession
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) {
Expand Down Expand Up @@ -227,7 +238,7 @@ public Response oauth2DeviceFlow() {
"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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1104,6 +1104,52 @@ public void testMultipleClientsBackchannelAuthenticationFlows() throws Exception
}
}

@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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -59,11 +60,13 @@
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:[email protected]">Hiroyuki Wada</a>
Expand Down Expand Up @@ -106,6 +109,7 @@ public void addTestRealms(List<RealmRepresentation> testRealms) {

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);

Expand Down Expand Up @@ -243,6 +247,72 @@ public void testCustomVerificationUri() throws Exception {
}
}

@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
Expand Down

0 comments on commit 28aa1d7

Please sign in to comment.