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 4, 2022
1 parent 067533c commit 5888a4d
Show file tree
Hide file tree
Showing 10 changed files with 324 additions and 35 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;
static final String NO_OIDC_COOKIES_AVAILABLE = "no_oidc_cookies";
Expand All @@ -59,6 +65,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 All @@ -82,8 +89,9 @@ public Uni<SecurityIdentity> apply(TenantConfigContext tenantContext) {
// if the state cookie is available then try to complete the code flow and start a new session
if (stateCookie != null) {
String[] parsedStateCookieValue = COOKIE_PATTERN.split(stateCookie.getValue());
OidcUtils.removeCookie(context, oidcTenantConfig, stateCookie.getName());

if (!isStateValid(context, parsedStateCookieValue[0])) {
OidcUtils.removeCookie(context, oidcTenantConfig, stateCookie.getName());
return Uni.createFrom().failure(new AuthenticationCompletionException());
}

Expand All @@ -95,13 +103,12 @@ public Uni<SecurityIdentity> apply(TenantConfigContext tenantContext) {
.transformToUni(new Function<TenantConfigContext, Uni<? extends SecurityIdentity>>() {
@Override
public Uni<SecurityIdentity> apply(TenantConfigContext tenantContext) {
return performCodeFlow(identityProviderManager, context, tenantContext, code, stateCookie,
return performCodeFlow(identityProviderManager, context, tenantContext, code,
parsedStateCookieValue);
}
});
} else {
LOG.debug("State cookie is present but neither 'code' nor 'error' query parameter is returned");
OidcUtils.removeCookie(context, oidcTenantConfig, stateCookie.getName());
return Uni.createFrom().failure(new AuthenticationCompletionException());
}
}
Expand Down Expand Up @@ -275,9 +282,22 @@ && isRedirectFromProvider(context, configContext)) {
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 @@ -297,31 +317,58 @@ private boolean isRedirectFromProvider(RoutingContext context, TenantConfigConte
return referer != null && referer.startsWith(configContext.provider.getMetadata().getAuthorizationUri());
}

private 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,
String[] parsedStateCookieValue) {
RoutingContext context, TenantConfigContext configContext, String code, String[] parsedStateCookieValue) {

String userPath = null;
String userQuery = null;

// This is an original redirect from IDP, check if the original request path and query need to be restored
if (parsedStateCookieValue.length == 2) {
int userQueryIndex = parsedStateCookieValue[1].indexOf("?");
CodeAuthenticationStateBean stateBean = getCodeAuthenticationBean(parsedStateCookieValue, configContext.oidcConfig);
if (stateBean != null && stateBean.getRestorePath() != null) {
String restorePath = stateBean.getRestorePath();
int userQueryIndex = restorePath.indexOf("?");
if (userQueryIndex >= 0) {
userPath = parsedStateCookieValue[1].substring(0, userQueryIndex);
if (userQueryIndex + 1 < parsedStateCookieValue[1].length()) {
userQuery = parsedStateCookieValue[1].substring(userQueryIndex + 1);
userPath = restorePath.substring(0, userQueryIndex);
if (userQueryIndex + 1 < restorePath.length()) {
userQuery = restorePath.substring(userQueryIndex + 1);
}
} else {
userPath = parsedStateCookieValue[1];
userPath = restorePath;
}
}
OidcUtils.removeCookie(context, configContext.oidcConfig, stateCookie.getName());

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 @@ -402,9 +449,34 @@ public Throwable apply(Throwable tInner) {

}

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

String pkceSecret = oidcConfig.authentication.pkceSecret
.orElse(OidcCommonUtils.clientSecret(oidcConfig.credentials));
JsonObject json = null;
try {
json = OidcUtils.decryptJson(parsedStateCookieValue[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 @@ -475,28 +547,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.oidcConfig), 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.oidcConfig));
return createCookie(context, configContext.oidcConfig, getPostLogoutCookieName(configContext.oidcConfig),
Expand Down Expand Up @@ -614,14 +708,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 5888a4d

Please sign in to comment.