Skip to content

Commit

Permalink
Device Authorization Grant with PKCE
Browse files Browse the repository at this point in the history
  • Loading branch information
cgeorgilakis authored and mposolda committed Feb 3, 2022
1 parent db4642d commit a1f2f77
Show file tree
Hide file tree
Showing 9 changed files with 228 additions and 88 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,8 @@ public class OAuth2DeviceCodeModel {
private static final String USER_SESSION_ID_NOTE = "uid";
private static final String DENIED_NOTE = "denied";
private static final String ADDITIONAL_PARAM_PREFIX = "additional_param_";
private static final String CODE_CHALLENGE = "codeChallenge";
private static final String CODE_CHALLENGE_METHOD = "codeChallengeMethod";

private final RealmModel realm;
private final String clientId;
Expand All @@ -52,33 +54,35 @@ public class OAuth2DeviceCodeModel {
private final String userSessionId;
private final Boolean denied;
private final Map<String, String> additionalParams;
private final String codeChallenge;
private final String codeChallengeMethod;

public static OAuth2DeviceCodeModel create(RealmModel realm, ClientModel client,
String deviceCode, String scope, String nonce, int expiresIn, int pollingInterval,
String clientNotificationToken, String authReqId, Map<String, String> additionalParams) {
String clientNotificationToken, String authReqId, Map<String, String> additionalParams, String codeChallenge, String codeChallengeMethod) {

int expiration = Time.currentTime() + expiresIn;
return new OAuth2DeviceCodeModel(realm, client.getClientId(), deviceCode, scope, nonce, expiration, pollingInterval, clientNotificationToken, authReqId, null, null, additionalParams);
return new OAuth2DeviceCodeModel(realm, client.getClientId(), deviceCode, scope, nonce, expiration, pollingInterval, clientNotificationToken, authReqId, null, null, additionalParams, codeChallenge, codeChallengeMethod);
}

public OAuth2DeviceCodeModel approve(String userSessionId) {
return new OAuth2DeviceCodeModel(realm, clientId, deviceCode, scope, nonce, expiration, pollingInterval, clientNotificationToken, authReqId, userSessionId, false, additionalParams);
return new OAuth2DeviceCodeModel(realm, clientId, deviceCode, scope, nonce, expiration, pollingInterval, clientNotificationToken, authReqId, userSessionId, false, additionalParams, codeChallenge, codeChallengeMethod);
}

public OAuth2DeviceCodeModel approve(String userSessionId, Map<String, String> additionalParams) {
if (additionalParams != null) {
this.additionalParams.putAll(additionalParams);
}
return new OAuth2DeviceCodeModel(realm, clientId, deviceCode, scope, nonce, expiration, pollingInterval, clientNotificationToken, authReqId, userSessionId, false, this.additionalParams);
return new OAuth2DeviceCodeModel(realm, clientId, deviceCode, scope, nonce, expiration, pollingInterval, clientNotificationToken, authReqId, userSessionId, false, this.additionalParams, codeChallenge, codeChallengeMethod);
}

public OAuth2DeviceCodeModel deny() {
return new OAuth2DeviceCodeModel(realm, clientId, deviceCode, scope, nonce, expiration, pollingInterval, clientNotificationToken, authReqId, null, true, additionalParams);
return new OAuth2DeviceCodeModel(realm, clientId, deviceCode, scope, nonce, expiration, pollingInterval, clientNotificationToken, authReqId, null, true, additionalParams, codeChallenge, codeChallengeMethod);
}

private OAuth2DeviceCodeModel(RealmModel realm, String clientId,
String deviceCode, String scope, String nonce, int expiration, int pollingInterval, String clientNotificationToken,
String authReqId, String userSessionId, Boolean denied, Map<String, String> additionalParams) {
String authReqId, String userSessionId, Boolean denied, Map<String, String> additionalParams, String codeChallenge, String codeChallengeMethod) {
this.realm = realm;
this.clientId = clientId;
this.deviceCode = deviceCode;
Expand All @@ -91,6 +95,8 @@ private OAuth2DeviceCodeModel(RealmModel realm, String clientId,
this.userSessionId = userSessionId;
this.denied = denied;
this.additionalParams = additionalParams;
this.codeChallenge = codeChallenge;
this.codeChallengeMethod = codeChallengeMethod;
}

public static OAuth2DeviceCodeModel fromCache(RealmModel realm, String deviceCode, Map<String, String> data) {
Expand All @@ -106,7 +112,7 @@ public static OAuth2DeviceCodeModel fromCache(RealmModel realm, String deviceCod
private OAuth2DeviceCodeModel(RealmModel realm, String deviceCode, Map<String, String> data) {
this(realm, data.get(CLIENT_ID), deviceCode, data.get(SCOPE_NOTE), data.get(NONCE_NOTE),
Integer.parseInt(data.get(EXPIRATION_NOTE)), Integer.parseInt(data.get(POLLING_INTERVAL_NOTE)), data.get(CLIENT_NOTIFICATION_TOKEN_NOTE),
data.get(AUTH_REQ_ID_NOTE), data.get(USER_SESSION_ID_NOTE), Boolean.parseBoolean(data.get(DENIED_NOTE)), extractAdditionalParams(data));
data.get(AUTH_REQ_ID_NOTE), data.get(USER_SESSION_ID_NOTE), Boolean.parseBoolean(data.get(DENIED_NOTE)), extractAdditionalParams(data), data.get(CODE_CHALLENGE), data.get(CODE_CHALLENGE_METHOD));
}

private static Map<String, String> extractAdditionalParams(Map<String, String> data) {
Expand Down Expand Up @@ -175,6 +181,14 @@ public String serializePollingKey() {
return createKey(deviceCode) + ".polling";
}

public String getCodeChallenge() {
return codeChallenge;
}

public String getCodeChallengeMethod() {
return codeChallengeMethod;
}

public Map<String, String> toMap() {
Map<String, String> result = new HashMap<>();

Expand Down Expand Up @@ -203,6 +217,10 @@ public Map<String, String> toMap() {
result.put(NONCE_NOTE, nonce);
result.put(USER_SESSION_ID_NOTE, userSessionId);
}
if (codeChallenge != null)
result.put(CODE_CHALLENGE, codeChallenge);
if (codeChallengeMethod != null)
result.put(CODE_CHALLENGE_METHOD, codeChallengeMethod);

additionalParams.forEach((key, value) -> result.put(ADDITIONAL_PARAM_PREFIX + key, value));

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -244,7 +244,7 @@ public void checkPKCEParams() throws AuthorizationCheckException {
// PKCE not adopted to OAuth2 Implicit Grant and OIDC Implicit Flow,
// adopted to OAuth2 Authorization Code Grant and OIDC Authorization Code Flow, Hybrid Flow
// Namely, flows using authorization code.
if (parsedResponseType.isImplicitFlow()) return;
if (parsedResponseType != null && parsedResponseType.isImplicitFlow()) return;

String pkceCodeChallengeMethod = OIDCAdvancedConfigWrapper.fromClientModel(client).getPkceCodeChallengeMethod();

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -114,8 +114,6 @@
import java.util.Map;
import java.util.Objects;
import java.util.function.Supplier;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.stream.Stream;

/**
Expand All @@ -132,9 +130,6 @@ private enum Action {
AUTHORIZATION_CODE, REFRESH_TOKEN, PASSWORD, CLIENT_CREDENTIALS, TOKEN_EXCHANGE, PERMISSION, OAUTH2_DEVICE_CODE, CIBA
}

// https://tools.ietf.org/html/rfc7636#section-4.2
private static final Pattern VALID_CODE_VERIFIER_PATTERN = Pattern.compile("^[0-9a-zA-Z\\-\\.~_]+$");

@Context
private KeycloakSession session;

Expand Down Expand Up @@ -404,10 +399,10 @@ public Response codeToToken() {
}

if (codeChallengeMethod != null && !codeChallengeMethod.isEmpty()) {
checkParamsForPkceEnforcedClient(codeVerifier, codeChallenge, codeChallengeMethod, authUserId, authUsername);
PkceUtils.checkParamsForPkceEnforcedClient(codeVerifier, codeChallenge, codeChallengeMethod, authUserId, authUsername, event, cors);
} else {
// PKCE Activation is OFF, execute the codes implemented in KEYCLOAK-2604
checkParamsForPkceNotEnforcedClient(codeVerifier, codeChallenge, codeChallengeMethod, authUserId, authUsername);
PkceUtils.checkParamsForPkceNotEnforcedClient(codeVerifier, codeChallenge, codeChallengeMethod, authUserId, authUsername, event, cors);
}

try {
Expand Down Expand Up @@ -491,63 +486,6 @@ private void checkMtlsHoKToken(TokenManager.AccessTokenResponseBuilder responseB
}
}

private void checkParamsForPkceEnforcedClient(String codeVerifier, String codeChallenge, String codeChallengeMethod, String authUserId, String authUsername) {
// check whether code verifier is specified
if (codeVerifier == null) {
logger.warnf("PKCE code verifier not specified, authUserId = %s, authUsername = %s", authUserId, authUsername);
event.error(Errors.CODE_VERIFIER_MISSING);
throw new CorsErrorResponseException(cors, OAuthErrorException.INVALID_GRANT, "PKCE code verifier not specified", Response.Status.BAD_REQUEST);
}
verifyCodeVerifier(codeVerifier, codeChallenge, codeChallengeMethod, authUserId, authUsername);
}

private void checkParamsForPkceNotEnforcedClient(String codeVerifier, String codeChallenge, String codeChallengeMethod, String authUserId, String authUsername) {
if (codeChallenge != null && codeVerifier == null) {
logger.warnf("PKCE code verifier not specified, authUserId = %s, authUsername = %s", authUserId, authUsername);
event.error(Errors.CODE_VERIFIER_MISSING);
throw new CorsErrorResponseException(cors, OAuthErrorException.INVALID_GRANT, "PKCE code verifier not specified", Response.Status.BAD_REQUEST);
}

if (codeChallenge != null) {
verifyCodeVerifier(codeVerifier, codeChallenge, codeChallengeMethod, authUserId, authUsername);
}
}

private void verifyCodeVerifier(String codeVerifier, String codeChallenge, String codeChallengeMethod, String authUserId, String authUsername) {
// check whether code verifier is formatted along with the PKCE specification

if (!isValidPkceCodeVerifier(codeVerifier)) {
logger.infof("PKCE invalid code verifier");
event.error(Errors.INVALID_CODE_VERIFIER);
throw new CorsErrorResponseException(cors, OAuthErrorException.INVALID_GRANT, "PKCE invalid code verifier", Response.Status.BAD_REQUEST);
}

logger.debugf("PKCE supporting Client, codeVerifier = %s", codeVerifier);
String codeVerifierEncoded = codeVerifier;
try {
// https://tools.ietf.org/html/rfc7636#section-4.2
// plain or S256
if (codeChallengeMethod != null && codeChallengeMethod.equals(OAuth2Constants.PKCE_METHOD_S256)) {
logger.debugf("PKCE codeChallengeMethod = %s", codeChallengeMethod);
codeVerifierEncoded = PkceUtils.generateS256CodeChallenge(codeVerifier);
} else {
logger.debug("PKCE codeChallengeMethod is plain");
codeVerifierEncoded = codeVerifier;
}
} catch (Exception nae) {
logger.infof("PKCE code verification failed, not supported algorithm specified");
event.error(Errors.PKCE_VERIFICATION_FAILED);
throw new CorsErrorResponseException(cors, OAuthErrorException.INVALID_GRANT, "PKCE code verification failed, not supported algorithm specified", Response.Status.BAD_REQUEST);
}
if (!codeChallenge.equals(codeVerifierEncoded)) {
logger.warnf("PKCE verification failed. authUserId = %s, authUsername = %s", authUserId, authUsername);
event.error(Errors.PKCE_VERIFICATION_FAILED);
throw new CorsErrorResponseException(cors, OAuthErrorException.INVALID_GRANT, "PKCE verification failed", Response.Status.BAD_REQUEST);
} else {
logger.debugf("PKCE verification success. codeVerifierEncoded = %s, codeChallenge = %s", codeVerifierEncoded, codeChallenge);
}
}

public Response refreshTokenGrant() {
String refreshToken = formParams.getFirst(OAuth2Constants.REFRESH_TOKEN);
if (refreshToken == null) {
Expand Down Expand Up @@ -1003,20 +941,6 @@ public Response cibaGrant() {
return grantType.cibaGrant();
}

// https://tools.ietf.org/html/rfc7636#section-4.1
private boolean isValidPkceCodeVerifier(String codeVerifier) {
if (codeVerifier.length() < OIDCLoginProtocol.PKCE_CODE_VERIFIER_MIN_LENGTH) {
logger.infof(" Error: PKCE codeVerifier length under lower limit , codeVerifier = %s", codeVerifier);
return false;
}
if (codeVerifier.length() > OIDCLoginProtocol.PKCE_CODE_VERIFIER_MAX_LENGTH) {
logger.infof(" Error: PKCE codeVerifier length over upper limit , codeVerifier = %s", codeVerifier);
return false;
}
Matcher m = VALID_CODE_VERIFIER_PATTERN.matcher(codeVerifier);
return m.matches();
}

public static class TokenExchangeSamlProtocol extends SamlProtocol {

final SamlClient samlClient;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -139,7 +139,7 @@ private void storeAuthenticationRequest(CIBAAuthenticationRequest request, CibaC

OAuth2DeviceCodeModel deviceCode = OAuth2DeviceCodeModel.create(realm, client,
request.getId(), request.getScope(), null, expiresIn, poolingInterval, request.getClientNotificationToken(), authReqId,
Collections.emptyMap());
Collections.emptyMap(), null, null);
String authResultId = request.getAuthResultId();
OAuth2DeviceUserCodeModel userCode = new OAuth2DeviceUserCodeModel(realm, deviceCode.getDeviceCode(),
authResultId);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@
import org.keycloak.protocol.oidc.endpoints.TokenEndpoint;
import org.keycloak.protocol.oidc.grants.device.clientpolicy.context.DeviceTokenRequestContext;
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.clientpolicy.ClientPolicyException;
import org.keycloak.services.managers.AuthenticationManager;
Expand Down Expand Up @@ -212,6 +213,18 @@ public Response oauth2DeviceFlow() {
"The authorization request is still pending", Response.Status.BAD_REQUEST);
}

// https://tools.ietf.org/html/rfc7636#section-4.6
String codeVerifier = formParams.getFirst(OAuth2Constants.CODE_VERIFIER);
String codeChallenge = deviceCodeModel.getCodeChallenge();
String codeChallengeMethod = deviceCodeModel.getCodeChallengeMethod();

if (codeChallengeMethod != null && !codeChallengeMethod.isEmpty()) {
PkceUtils.checkParamsForPkceEnforcedClient(codeVerifier, codeChallenge, codeChallengeMethod, null, null, event, cors);
} else {
// PKCE Activation is OFF, execute the codes implemented in KEYCLOAK-2604
PkceUtils.checkParamsForPkceNotEnforcedClient(codeVerifier, codeChallenge, codeChallengeMethod, null, null, event, cors);
}

// Approved

String userSessionId = deviceCodeModel.getUserSessionId();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@
import org.keycloak.models.RealmModel;
import org.keycloak.protocol.AuthorizationEndpointBase;
import org.keycloak.protocol.oidc.OIDCLoginProtocol;
import org.keycloak.protocol.oidc.endpoints.AuthorizationEndpointChecker;
import org.keycloak.protocol.oidc.endpoints.request.AuthorizationEndpointRequest;
import org.keycloak.protocol.oidc.endpoints.request.AuthorizationEndpointRequestParserProcessor;
import org.keycloak.protocol.oidc.grants.device.DeviceGrantType;
Expand Down Expand Up @@ -123,6 +124,18 @@ public Response handleDeviceRequest() {
"Client not allowed for OAuth 2.0 Device Authorization Grant", Response.Status.BAD_REQUEST);
}

// https://tools.ietf.org/html/rfc7636#section-4
AuthorizationEndpointChecker checker = new AuthorizationEndpointChecker()
.event(event)
.client(client)
.request(request);

try {
checker.checkPKCEParams();
} catch (AuthorizationEndpointChecker.AuthorizationCheckException ex) {
throw new ErrorResponseException(ex.getError(), ex.getErrorDescription(), Response.Status.BAD_REQUEST);
}

try {
session.clientPolicy().triggerOnEvent(new DeviceAuthorizationRequestContext(request, httpRequest.getDecodedFormParameters()));
} catch (ClientPolicyException cpe) {
Expand All @@ -134,7 +147,7 @@ public Response handleDeviceRequest() {

OAuth2DeviceCodeModel deviceCode = OAuth2DeviceCodeModel.create(realm, client,
Base64Url.encode(SecretGenerator.getInstance().randomBytes()), request.getScope(), request.getNonce(), expiresIn, interval, null, null,
request.getAdditionalReqParams());
request.getAdditionalReqParams(), request.getCodeChallenge(), request.getCodeChallengeMethod());
OAuth2DeviceUserCodeProvider userCodeProvider = session.getProvider(OAuth2DeviceUserCodeProvider.class);
String secret = userCodeProvider.generate();
OAuth2DeviceUserCodeModel userCode = new OAuth2DeviceUserCodeModel(realm, deviceCode.getDeviceCode(), secret);
Expand Down
Loading

0 comments on commit a1f2f77

Please sign in to comment.