Skip to content

Commit

Permalink
Support for OIDC Proof Of Key for Code Exchange (PKCE)
Browse files Browse the repository at this point in the history
  • Loading branch information
sberyozkin committed Feb 3, 2022
1 parent 8e968cc commit eb2f94c
Show file tree
Hide file tree
Showing 10 changed files with 321 additions and 29 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -49,4 +49,10 @@ public final class OidcConstants {

public static final String EXPIRES_IN = "expires_in";
public static final String REFRESH_EXPIRES_IN = "refresh_expires_in";

public static final String PKCE_CODE_VERIFIER = "code_verifier";
public static final String PKCE_CODE_CHALLENGE = "code_challenge";

public static final String PKCE_CODE_CHALLENGE_METHOD = "code_challenge_method";
public static final String PKCE_CODE_CHALLENGE_S256 = "S256";
}
Original file line number Diff line number Diff line change
Expand Up @@ -572,6 +572,36 @@ public static class Authentication {
@ConfigItem(defaultValueDocumentation = "true")
public Optional<Boolean> idTokenRequired = Optional.empty();

/**
* Requires that a Proof Key for Code Exchange (PKCE) is used.
*/
@ConfigItem(defaultValueDocumentation = "false")
public Optional<Boolean> pkceRequired = Optional.empty();

public Optional<Boolean> isPkceRequired() {
return pkceRequired;
}

/**
* Secret which will be used to encrypt a Proof Key for Code Exchange (PKCE) code verifier in the code flow state.
* This secret must be set if no client secret is set with 'quarkus.oidc.credentials.secret'.
* The length of the secret which will be used to encrypt the code verifier must be 32 characters long.
*/
@ConfigItem
public Optional<String> pkceSecret = Optional.empty();

public void setPkceRequired(boolean pkceRequired) {
this.pkceRequired = Optional.of(pkceRequired);
}

public Optional<String> getPkceSecret() {
return pkceSecret;
}

public void setPkceSecret(String pkceSecret) {
this.pkceSecret = Optional.of(pkceSecret);
}

public boolean isJavaScriptAutoRedirect() {
return javaScriptAutoRedirect;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,11 @@
import static io.quarkus.oidc.runtime.OidcIdentityProvider.REFRESH_TOKEN_GRANT_RESPONSE;

import java.net.URI;
import java.nio.charset.StandardCharsets;
import java.security.SecureRandom;
import java.util.ArrayList;
import java.util.Base64;
import java.util.Base64.Encoder;
import java.util.Collections;
import java.util.List;
import java.util.Map;
Expand Down Expand Up @@ -33,6 +37,7 @@
import io.quarkus.security.identity.SecurityIdentity;
import io.quarkus.vertx.http.runtime.security.ChallengeData;
import io.smallrye.jwt.build.Jwt;
import io.smallrye.jwt.build.JwtClaimsBuilder;
import io.smallrye.jwt.util.KeyUtils;
import io.smallrye.mutiny.Uni;
import io.vertx.core.http.Cookie;
Expand All @@ -50,6 +55,7 @@ public class CodeAuthenticationMechanism extends AbstractOidcAuthenticationMecha
static final String COOKIE_DELIM = "|";
static final Pattern COOKIE_PATTERN = Pattern.compile("\\" + COOKIE_DELIM);
static final String SESSION_MAX_AGE_PARAM = "session-max-age";
static final String STATE_COOKIE_RESTORE_PATH = "restore-path";
static final Uni<Void> VOID_UNI = Uni.createFrom().voidItem();
static final Integer MAX_COOKIE_VALUE_LENGTH = 4096;

Expand All @@ -58,6 +64,7 @@ public class CodeAuthenticationMechanism extends AbstractOidcAuthenticationMecha

private final BlockingTaskRunner<String> createTokenStateRequestContext = new BlockingTaskRunner<String>();
private final BlockingTaskRunner<AuthorizationCodeTokens> getTokenStateRequestContext = new BlockingTaskRunner<AuthorizationCodeTokens>();
private final SecureRandom secureRandom = new SecureRandom();

public Uni<SecurityIdentity> authenticate(RoutingContext context,
IdentityProviderManager identityProviderManager, OidcTenantConfig oidcTenantConfig) {
Expand Down Expand Up @@ -233,9 +240,22 @@ public Uni<ChallengeData> apply(Void t) {
codeFlowParams.append(AMP).append(OidcConstants.CODE_FLOW_REDIRECT_URI).append(EQ)
.append(OidcCommonUtils.urlEncode(redirectUriParam));

// pkce
PkceStateBean pkceStateBean = createPkceStateBean(configContext);

// state
codeFlowParams.append(AMP).append(OidcConstants.CODE_FLOW_STATE).append(EQ)
.append(generateCodeFlowState(context, configContext, redirectPath));
.append(generateCodeFlowState(context, configContext, redirectPath,
pkceStateBean != null ? pkceStateBean.getCodeVerifier() : null));

if (pkceStateBean != null) {
codeFlowParams
.append(AMP).append(OidcConstants.PKCE_CODE_CHALLENGE).append(EQ)
.append(pkceStateBean.getCodeChallenge());
codeFlowParams
.append(AMP).append(OidcConstants.PKCE_CODE_CHALLENGE_METHOD).append(EQ)
.append(OidcConstants.PKCE_CODE_CHALLENGE_S256);
}

// extra redirect parameters, see https://openid.net/specs/openid-connect-core-1_0.html#AuthRequests
addExtraParamsToUri(codeFlowParams, configContext.oidcConfig.authentication.getExtraParams());
Expand All @@ -250,13 +270,40 @@ public Uni<ChallengeData> apply(Void t) {
});
}

protected PkceStateBean createPkceStateBean(TenantConfigContext configContext) {
if (configContext.oidcConfig.authentication.pkceRequired.orElse(false)) {
PkceStateBean bean = new PkceStateBean();

Encoder encoder = Base64.getUrlEncoder().withoutPadding();

// code verifier
byte[] codeVerifierBytes = new byte[32];
secureRandom.nextBytes(codeVerifierBytes);
String codeVerifier = encoder.encodeToString(codeVerifierBytes);
bean.setCodeVerifier(codeVerifier);

// code challenge
try {
byte[] codeChallengeBytes = OidcUtils.getSha256Digest(codeVerifier.getBytes(StandardCharsets.US_ASCII));
String codeChallenge = encoder.encodeToString(codeChallengeBytes);
bean.setCodeChallenge(codeChallenge);
} catch (Exception ex) {
throw new AuthenticationFailedException(ex);
}

return bean;
}
return null;
}

private Uni<SecurityIdentity> performCodeFlow(IdentityProviderManager identityProviderManager,
RoutingContext context, TenantConfigContext configContext, String code) {

Cookie stateCookie = context.getCookie(getStateCookieName(configContext));

String userPath = null;
String userQuery = null;
CodeAuthenticationStateBean stateBean = null;
if (stateCookie != null) {
List<String> values = context.queryParam("state");
// IDP must return a 'state' query parameter and the value of the state cookie must start with this parameter's value
Expand All @@ -268,16 +315,17 @@ private Uni<SecurityIdentity> performCodeFlow(IdentityProviderManager identityPr
return Uni.createFrom().failure(new AuthenticationCompletionException());
} else {
// This is an original redirect from IDP, check if the original request path and query need to be restored
String[] pair = COOKIE_PATTERN.split(stateCookie.getValue());
if (pair.length == 2) {
int userQueryIndex = pair[1].indexOf("?");
stateBean = getCodeAuthenticationBean(stateCookie, configContext.oidcConfig);
if (stateBean != null && stateBean.getRestorePath() != null) {
String restorePath = stateBean.getRestorePath();
int userQueryIndex = restorePath.indexOf("?");
if (userQueryIndex >= 0) {
userPath = pair[1].substring(0, userQueryIndex);
if (userQueryIndex + 1 < pair[1].length()) {
userQuery = pair[1].substring(userQueryIndex + 1);
userPath = restorePath.substring(0, userQueryIndex);
if (userQueryIndex + 1 < restorePath.length()) {
userQuery = restorePath.substring(userQueryIndex + 1);
}
} else {
userPath = pair[1];
userPath = restorePath;
}
}
OidcUtils.removeCookie(context, configContext.oidcConfig, stateCookie.getName());
Expand All @@ -291,7 +339,8 @@ private Uni<SecurityIdentity> performCodeFlow(IdentityProviderManager identityPr
final String finalUserPath = userPath;
final String finalUserQuery = userQuery;

Uni<AuthorizationCodeTokens> codeFlowTokensUni = getCodeFlowTokensUni(context, configContext, code);
Uni<AuthorizationCodeTokens> codeFlowTokensUni = getCodeFlowTokensUni(context, configContext, code,
stateBean != null ? stateBean.getCodeVerifier() : null);

return codeFlowTokensUni
.onItemOrFailure()
Expand Down Expand Up @@ -372,9 +421,34 @@ public Throwable apply(Throwable tInner) {

}

private CodeAuthenticationStateBean getCodeAuthenticationBean(Cookie stateCookie, OidcTenantConfig oidcConfig) {
String[] parsedCookieState = COOKIE_PATTERN.split(stateCookie.getValue());
if (parsedCookieState.length == 2) {
CodeAuthenticationStateBean bean = new CodeAuthenticationStateBean();
if (!oidcConfig.authentication.pkceRequired.orElse(false)) {
bean.setRestorePath(parsedCookieState[1]);
return bean;
}

String pkceSecret = oidcConfig.authentication.pkceSecret
.orElse(OidcCommonUtils.clientSecret(oidcConfig.credentials));
JsonObject json = null;
try {
json = OidcUtils.decryptJson(parsedCookieState[1], KeyUtils.createSecretKeyFromSecret(pkceSecret));
} catch (Exception ex) {
LOG.tracef("State cookie value can not be decrypted for the %s tenant", oidcConfig.tenantId.get());
throw new AuthenticationFailedException(ex);
}
bean.setRestorePath(json.getString(STATE_COOKIE_RESTORE_PATH));
bean.setCodeVerifier(json.getString(OidcConstants.PKCE_CODE_VERIFIER));
return bean;
}
return null;
}

private String generateInternalIdToken(OidcTenantConfig oidcConfig) {
return Jwt.claims().jws().header(INTERNAL_IDTOKEN_HEADER, true)
.sign(KeyUtils.createSecretKeyFromSecret(oidcConfig.credentials.secret.get()));
.sign(KeyUtils.createSecretKeyFromSecret(OidcCommonUtils.clientSecret(oidcConfig.credentials)));
}

private Uni<Void> processSuccessfulAuthentication(RoutingContext context,
Expand Down Expand Up @@ -445,28 +519,50 @@ private String getRedirectPath(TenantConfigContext configContext, RoutingContext
}

private String generateCodeFlowState(RoutingContext context, TenantConfigContext configContext,
String redirectPath) {
String redirectPath, String pkceCodeVerifier) {
String uuid = UUID.randomUUID().toString();
String cookieValue = uuid;

Authentication auth = configContext.oidcConfig.getAuthentication();
boolean restorePath = auth.isRestorePathAfterRedirect() || !auth.redirectPath.isPresent();
if (restorePath) {
String requestQuery = context.request().query();
String requestPath = !redirectPath.equals(context.request().path()) || requestQuery != null
? context.request().path()
: "";
if (requestQuery != null) {
requestPath += ("?" + requestQuery);
if (restorePath || pkceCodeVerifier != null) {
CodeAuthenticationStateBean extraStateValue = new CodeAuthenticationStateBean();
if (restorePath) {
String requestQuery = context.request().query();
String requestPath = !redirectPath.equals(context.request().path()) || requestQuery != null
? context.request().path()
: "";
if (requestQuery != null) {
requestPath += ("?" + requestQuery);
}
if (!requestPath.isEmpty()) {
extraStateValue.setRestorePath(requestPath);
}
}
if (!requestPath.isEmpty()) {
cookieValue += (COOKIE_DELIM + requestPath);
extraStateValue.setCodeVerifier(pkceCodeVerifier);
if (!extraStateValue.isEmpty()) {
cookieValue += (COOKIE_DELIM + encodeExtraStateValue(extraStateValue, configContext.oidcConfig));
}
}
createCookie(context, configContext.oidcConfig, getStateCookieName(configContext), cookieValue, 60 * 30);
return uuid;
}

private String encodeExtraStateValue(CodeAuthenticationStateBean extraStateValue, OidcTenantConfig oidcConfig) {
if (extraStateValue.getCodeVerifier() != null) {
String pkceSecret = oidcConfig.authentication.pkceSecret
.orElse(OidcCommonUtils.clientSecret(oidcConfig.credentials));
JwtClaimsBuilder claims = Jwt.claim(OidcConstants.PKCE_CODE_VERIFIER, extraStateValue.getCodeVerifier());
if (extraStateValue.getRestorePath() != null) {
claims.claim(STATE_COOKIE_RESTORE_PATH, extraStateValue.getRestorePath());
}
return claims.jwe().encrypt(KeyUtils.createSecretKeyFromSecret(pkceSecret));
} else {
return extraStateValue.getRestorePath();
}

}

private String generatePostLogoutState(RoutingContext context, TenantConfigContext configContext) {
OidcUtils.removeCookie(context, configContext.oidcConfig, getPostLogoutCookieName(configContext));
return createCookie(context, configContext.oidcConfig, getPostLogoutCookieName(configContext),
Expand Down Expand Up @@ -584,14 +680,14 @@ private Uni<AuthorizationCodeTokens> refreshTokensUni(TenantConfigContext config
}

private Uni<AuthorizationCodeTokens> getCodeFlowTokensUni(RoutingContext context, TenantConfigContext configContext,
String code) {
String code, String codeVerifier) {

// 'redirect_uri': typically it must match the 'redirect_uri' query parameter which was used during the code request.
String redirectPath = getRedirectPath(configContext, context);
String redirectUriParam = buildUri(context, isForceHttps(configContext), redirectPath);
LOG.debugf("Token request redirect_uri parameter: %s", redirectUriParam);

return configContext.provider.getCodeFlowTokens(code, redirectUriParam);
return configContext.provider.getCodeFlowTokens(code, redirectUriParam, codeVerifier);
}

private String buildLogoutRedirectUri(TenantConfigContext configContext, String idToken, RoutingContext context) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
package io.quarkus.oidc.runtime;

public class CodeAuthenticationStateBean {

private String restorePath;

private String codeVerifier;

public String getRestorePath() {
return restorePath;
}

public void setRestorePath(String restorePath) {
this.restorePath = restorePath;
}

public String getCodeVerifier() {
return codeVerifier;
}

public void setCodeVerifier(String codeVerifier) {
this.codeVerifier = codeVerifier;
}

public boolean isEmpty() {
return this.restorePath == null && this.codeVerifier == null;
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -197,8 +197,8 @@ public Uni<UserInfo> getUserInfo(String accessToken) {
return client.getUserInfo(accessToken);
}

public Uni<AuthorizationCodeTokens> getCodeFlowTokens(String code, String redirectUri) {
return client.getAuthorizationCodeTokens(code, redirectUri);
public Uni<AuthorizationCodeTokens> getCodeFlowTokens(String code, String redirectUri, String codeVerifier) {
return client.getAuthorizationCodeTokens(code, redirectUri, codeVerifier);
}

public Uni<AuthorizationCodeTokens> refreshTokens(String refreshToken) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -87,11 +87,14 @@ public OidcTenantConfig getOidcConfig() {
return oidcConfig;
}

public Uni<AuthorizationCodeTokens> getAuthorizationCodeTokens(String code, String redirectUri) {
public Uni<AuthorizationCodeTokens> getAuthorizationCodeTokens(String code, String redirectUri, String codeVerifier) {
MultiMap codeGrantParams = new MultiMap(io.vertx.core.MultiMap.caseInsensitiveMultiMap());
codeGrantParams.add(OidcConstants.GRANT_TYPE, OidcConstants.AUTHORIZATION_CODE);
codeGrantParams.add(OidcConstants.CODE_FLOW_CODE, code);
codeGrantParams.add(OidcConstants.CODE_FLOW_REDIRECT_URI, redirectUri);
if (codeVerifier != null) {
codeGrantParams.add(OidcConstants.PKCE_CODE_VERIFIER, codeVerifier);
}
return getHttpResponse(metadata.getTokenUri(), codeGrantParams).transform(resp -> getAuthorizationCodeTokens(resp));
}

Expand Down
Loading

0 comments on commit eb2f94c

Please sign in to comment.