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 6045c83
Show file tree
Hide file tree
Showing 11 changed files with 345 additions and 35 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -592,6 +592,24 @@ public class CustomTokenStateManager implements TokenStateManager {
}
----

=== Proof Of Key for Code Exchange (PKCE)

link:https://datatracker.ietf.org/doc/html/rfc7636[Proof Of Key for Code Exchange] (PKCE) minimizes the risk of the authorization code interception.

While `PKCE` is of primary importance to the public OpenId Connect clients (such as the SPA scripts running in a browser), it can also provide an extra level of protection to Quarkus OIDC `web-app` applications which are confidential OpenId Connect clients capable of securely storing the client secret and using it to exchange the code for the tokens.

If can enable `PKCE` for your OIDC `web-app` endpoint with a `quarkus.oidc.authentication.pkce-required` property and a 32 characters long secret, for example:

[source, properties]
----
quarkus.oidc.authentication.pkce-required
quarkus.oidc.authentication.pkce-secret=eUk1p7UB3nFiXZGUXi0uph1Y9p34YhBU
----

If you already have a 32 character long client secret then `quarkus.oidc.authentication.pkce-secret` does not have to be set unless you prefer to use a different secret key.

The secret key is required for encrypting a randomly generated `PKCE` `code_verifier` while the user is being redirected with the `code_challenge` query parameter to OpenId Connect Provider to authenticate. The `code_verifier` will be decrypted when the user is redirected back to Quarkus and sent to the token endpoint alongside the `code`, client secret and other parameters to complete the code exchange. The provider will fail the code exchange if a `SHA256` digest of the `code_verifier` does not match the `code_challenge` provided during the authentication request.

=== Listening to important authentication events

One can register `@ApplicationScoped` bean which will observe important OIDC authentication events. The listener will be updated when a user has logged in for the first time or re-authenticated, as well as when the session has been refreshed. More events may be reported in the future. For example:
Expand Down
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();

/**
* 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 PKCE is required but no client secret is set.
* 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 Optional<Boolean> isPkceRequired() {
return pkceRequired;
}

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
Loading

0 comments on commit 6045c83

Please sign in to comment.