Skip to content

Commit

Permalink
Support OIDC authorization code flow nonce
Browse files Browse the repository at this point in the history
  • Loading branch information
sberyozkin committed Jul 26, 2023
1 parent 48bd9c7 commit 095f4fe
Show file tree
Hide file tree
Showing 10 changed files with 265 additions and 40 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -469,15 +469,15 @@ link:https://datatracker.ietf.org/doc/html/rfc7636[Proof Key for Code Exchange]
While PKCE is of primary importance to public OpenID Connect clients, such as SPA scripts running in a browser, it can also provide an extra level of protection to Quarkus OIDC `web-app` applications.
With PKCE, Quarkus OIDC `web-app` applications are confidential OpenID Connect clients capable of securely storing the client secret and using it to exchange the code for the tokens.

You can enable `PKCE` for your OIDC `web-app` endpoint with a `quarkus.oidc.authentication.pkce-required` property and a 32-character secret, as shown in the following example:
You can enable `PKCE` for your OIDC `web-app` endpoint with a `quarkus.oidc.authentication.pkce-required` property and a 32-character secret whixh is required to encrypt the PKCE code verifier in the state cookie, as shown in the following example:

Check warning on line 472 in docs/src/main/asciidoc/security-oidc-code-flow-authentication.adoc

View workflow job for this annotation

GitHub Actions / Linting with Vale

[vale] reported by reviewdog 🐶 [Quarkus.Spelling] Use correct American English spelling. Did you really mean 'whixh'? Raw Output: {"message": "[Quarkus.Spelling] Use correct American English spelling. Did you really mean 'whixh'?", "location": {"path": "docs/src/main/asciidoc/security-oidc-code-flow-authentication.adoc", "range": {"start": {"line": 472, "column": 142}}}, "severity": "WARNING"}

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

If you already have a 32-characters client secret then you do not need to set the `quarkus.oidc.authentication.pkce-secret` property unless you prefer to use a different secret key.
If you already have a 32-characters client secret then you do not need to set the `quarkus.oidc.authentication.pkce-secret` property unless you prefer to use a different secret key. This secret will be auto-generated if it is not configured and if the fallback to the client secret is not possible in case of the client secret being less than 16 characters long.

Check warning on line 480 in docs/src/main/asciidoc/security-oidc-code-flow-authentication.adoc

View workflow job for this annotation

GitHub Actions / Linting with Vale

[vale] reported by reviewdog 🐶 [Quarkus.Fluff] Depending on the context, consider using 'Rewrite the sentence, or use 'must', instead of' rather than 'need to'. Raw Output: {"message": "[Quarkus.Fluff] Depending on the context, consider using 'Rewrite the sentence, or use 'must', instead of' rather than 'need to'.", "location": {"path": "docs/src/main/asciidoc/security-oidc-code-flow-authentication.adoc", "range": {"start": {"line": 480, "column": 67}}}, "severity": "INFO"}

Check warning on line 480 in docs/src/main/asciidoc/security-oidc-code-flow-authentication.adoc

View workflow job for this annotation

GitHub Actions / Linting with Vale

[vale] reported by reviewdog 🐶 [Quarkus.SentenceLength] Try to keep sentences to an average of 32 words or fewer. Raw Output: {"message": "[Quarkus.SentenceLength] Try to keep sentences to an average of 32 words or fewer.", "location": {"path": "docs/src/main/asciidoc/security-oidc-code-flow-authentication.adoc", "range": {"start": {"line": 480, "column": 184}}}, "severity": "INFO"}

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 an OIDC provider to authenticate.
The `code_verifier` is 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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -386,7 +386,7 @@ Twitter provider requires Proof Key for Code Exchange (PKCE) which is supported
Quarkus has to encrypt the current PKCE code verifier in a state cookie while the authorization code flow with Twitter is in progress and it will
generate a secure random secret key for encrypting it.
You can provide your own secret key for encrypting the PKCE code verifier if you prefer with the `quarkus.oidc.authentication.pkce-secret` property but
You can provide your own secret key for encrypting the PKCE code verifier if you prefer with the `quarkus.oidc.authentication.state-secret` property but

Check warning on line 389 in docs/src/main/asciidoc/security-openid-connect-providers.adoc

View workflow job for this annotation

GitHub Actions / Linting with Vale

[vale] reported by reviewdog 🐶 [Quarkus.SentenceLength] Try to keep sentences to an average of 32 words or fewer. Raw Output: {"message": "[Quarkus.SentenceLength] Try to keep sentences to an average of 32 words or fewer.", "location": {"path": "docs/src/main/asciidoc/security-openid-connect-providers.adoc", "range": {"start": {"line": 389, "column": 1}}}, "severity": "INFO"}
note that this secret should be 32 characters long, and an error will be reported if it is less than 16 characters long.

Check warning on line 390 in docs/src/main/asciidoc/security-openid-connect-providers.adoc

View workflow job for this annotation

GitHub Actions / Linting with Vale

[vale] reported by reviewdog 🐶 [Quarkus.Fluff] Depending on the context, consider using 'Be concise: rewrite the sentence to not use' rather than 'note that'. Raw Output: {"message": "[Quarkus.Fluff] Depending on the context, consider using 'Be concise: rewrite the sentence to not use' rather than 'note that'.", "location": {"path": "docs/src/main/asciidoc/security-openid-connect-providers.adoc", "range": {"start": {"line": 390, "column": 1}}}, "severity": "INFO"}
====

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -73,4 +73,5 @@ public final class OidcConstants {
public static final String ID_TOKEN_SID_CLAIM = "sid";

public static final String OPENID_SCOPE = "openid";
public static final String NONCE = "nonce";
}
Original file line number Diff line number Diff line change
Expand Up @@ -781,6 +781,15 @@ public enum ResponseMode {
@ConfigItem
public Optional<List<String>> scopes = Optional.empty();

/**
* Require that ID token includes `nonce` claim which must match `nonce` authentication request query parameter.
* Enabling this property can help mitigate replay attacks.
* Do not enable this property if your OpenId Connect provider does not support setting `nonce` in ID token
* or if you work with OAuth2 provider such as `GitHub` which does not issue ID tokens.
*/
@ConfigItem
public boolean nonceRequired = false;

/**
* Add the 'openid' scope automatically to the list of scopes. This is required for OpenId Connect providers
* but will not work for OAuth2 providers such as Twitter OAuth2 which does not accept that scope and throws an error.
Expand Down Expand Up @@ -955,10 +964,30 @@ public enum ResponseMode {
* The secret will be auto-generated if it remains uninitialized after checking all of these properties.
* <p/>
* Error will be reported if the secret length is less than 16 characters.
*
*/
@ConfigItem
@Deprecated(forRemoval = true)
public Optional<String> pkceSecret = Optional.empty();

/**
* Secret which will be used to encrypt Proof Key for Code Exchange (PKCE) code verifier and/or nonce in the code flow
* state.
* This secret should be at least 32 characters long.
* <p/>
* If this secret is not set, the client secret configured with
* either `quarkus.oidc.credentials.secret` or `quarkus.oidc.credentials.client-secret.value` will be checked.
* Finally, `quarkus.oidc.credentials.jwt.secret` which can be used for `client_jwt_secret` authentication will be
* checked. Client secret will not be used as a state encryption secret if it is less than 32 characters
* long.
* </p>
* The secret will be auto-generated if it remains uninitialized after checking all of these properties.
* <p/>
* Error will be reported if the secret length is less than 16 characters.
*/
@ConfigItem
public Optional<String> stateSecret = Optional.empty();

public Optional<Duration> getInternalIdTokenLifespan() {
return internalIdTokenLifespan;
}
Expand All @@ -975,10 +1004,12 @@ public void setPkceRequired(boolean pkceRequired) {
this.pkceRequired = Optional.of(pkceRequired);
}

@Deprecated(forRemoval = true)
public Optional<String> getPkceSecret() {
return pkceSecret;
}

@Deprecated(forRemoval = true)
public void setPkceSecret(String pkceSecret) {
this.pkceSecret = Optional.of(pkceSecret);
}
Expand Down Expand Up @@ -1158,6 +1189,22 @@ public boolean isAllowMultipleCodeFlows() {
public void setAllowMultipleCodeFlows(boolean allowMultipleCodeFlows) {
this.allowMultipleCodeFlows = allowMultipleCodeFlows;
}

public boolean isNonceRequired() {
return nonceRequired;
}

public void setNonceRequired(boolean nonceRequired) {
this.nonceRequired = nonceRequired;
}

public Optional<String> getStateSecret() {
return stateSecret;
}

public void setStateSecret(Optional<String> stateSecret) {
this.stateSecret = stateSecret;
}
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -623,9 +623,12 @@ && isRedirectFromProvider(context, configContext)) {
PkceStateBean pkceStateBean = createPkceStateBean(configContext);

// state
String nonce = configContext.oidcConfig.authentication.nonceRequired ? UUID.randomUUID().toString()
: null;

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

if (pkceStateBean != null) {
codeFlowParams
Expand All @@ -636,6 +639,10 @@ && isRedirectFromProvider(context, configContext)) {
.append(OidcConstants.PKCE_CODE_CHALLENGE_S256);
}

if (nonce != null) {
codeFlowParams.append(AMP).append(OidcConstants.NONCE).append(EQ).append(nonce);
}

// extra redirect parameters, see https://openid.net/specs/openid-connect-core-1_0.html#AuthRequests
addExtraParamsToUri(codeFlowParams, configContext.oidcConfig.authentication.getExtraParams());

Expand Down Expand Up @@ -739,6 +746,9 @@ public Uni<SecurityIdentity> apply(final AuthorizationCodeTokens tokens, final T
internalIdToken = true;
}
} else {
if (!verifyNonce(configContext.oidcConfig, stateBean, tokens.getIdToken())) {
return Uni.createFrom().failure(new AuthenticationCompletionException());
}
internalIdToken = false;
}

Expand Down Expand Up @@ -814,6 +824,21 @@ public Throwable apply(Throwable tInner) {
});
}

private static boolean verifyNonce(OidcTenantConfig oidcConfig, CodeAuthenticationStateBean stateBean, String idToken) {
if (oidcConfig.authentication.nonceRequired) {
if (stateBean != null && stateBean.getNonce() != null) {
JsonObject idTokenClaims = OidcUtils.decodeJwtContent(idToken);
if (stateBean.getNonce().equals(idTokenClaims.getString(OidcConstants.NONCE))) {
return true;
}
}
LOG.errorf("ID token 'nonce' does not match the authentication request 'nonce' value");
return false;
} else {
return true;
}
}

private static Object errorMessage(Throwable t) {
return t.getCause() != null ? t.getCause().getMessage() : t.getMessage();
}
Expand All @@ -822,21 +847,24 @@ private CodeAuthenticationStateBean getCodeAuthenticationBean(String[] parsedSta
TenantConfigContext configContext) {
if (parsedStateCookieValue.length == 2) {
CodeAuthenticationStateBean bean = new CodeAuthenticationStateBean();
if (!configContext.oidcConfig.authentication.pkceRequired.orElse(false)) {
Authentication authentication = configContext.oidcConfig.authentication;
boolean pkceRequired = authentication.pkceRequired.orElse(false);
if (!pkceRequired && !authentication.nonceRequired) {
bean.setRestorePath(parsedStateCookieValue[1]);
return bean;
}

JsonObject json = null;
try {
json = OidcUtils.decryptJson(parsedStateCookieValue[1], configContext.getPkceSecretKey());
json = OidcUtils.decryptJson(parsedStateCookieValue[1], configContext.getStateEncryptionKey());
} catch (Exception ex) {
LOG.errorf("State cookie value can not be decrypted for the %s tenant",
configContext.oidcConfig.tenantId.get());
throw new AuthenticationCompletionException(ex);
}
bean.setRestorePath(json.getString(STATE_COOKIE_RESTORE_PATH));
bean.setCodeVerifier(json.getString(OidcConstants.PKCE_CODE_VERIFIER));
bean.setNonce(json.getString(OidcConstants.NONCE));
return bean;
}
return null;
Expand Down Expand Up @@ -943,12 +971,13 @@ private String getRedirectPath(OidcTenantConfig oidcConfig, RoutingContext conte
}

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

boolean restorePath = isRestorePath(configContext.oidcConfig.getAuthentication());
if (restorePath || pkceCodeVerifier != null) {
Authentication authentication = configContext.oidcConfig.getAuthentication();
boolean restorePath = isRestorePath(authentication);
if (restorePath || pkceCodeVerifier != null || nonce != null) {
CodeAuthenticationStateBean extraStateValue = new CodeAuthenticationStateBean();
if (restorePath) {
String requestQuery = context.request().query();
Expand Down Expand Up @@ -978,6 +1007,7 @@ private String generateCodeFlowState(RoutingContext context, TenantConfigContext
}
}
extraStateValue.setCodeVerifier(pkceCodeVerifier);
extraStateValue.setNonce(nonce);
if (!extraStateValue.isEmpty()) {
cookieValue += (COOKIE_DELIM + encodeExtraStateValue(extraStateValue, configContext));
}
Expand All @@ -997,14 +1027,19 @@ private boolean isRestorePath(Authentication auth) {
}

private String encodeExtraStateValue(CodeAuthenticationStateBean extraStateValue, TenantConfigContext configContext) {
if (extraStateValue.getCodeVerifier() != null) {
if (extraStateValue.getCodeVerifier() != null || extraStateValue.getNonce() != null) {
JsonObject json = new JsonObject();
json.put(OidcConstants.PKCE_CODE_VERIFIER, extraStateValue.getCodeVerifier());
if (extraStateValue.getCodeVerifier() != null) {
json.put(OidcConstants.PKCE_CODE_VERIFIER, extraStateValue.getCodeVerifier());
}
if (extraStateValue.getNonce() != null) {
json.put(OidcConstants.NONCE, extraStateValue.getNonce());
}
if (extraStateValue.getRestorePath() != null) {
json.put(STATE_COOKIE_RESTORE_PATH, extraStateValue.getRestorePath());
}
try {
return OidcUtils.encryptJson(json, configContext.getPkceSecretKey());
return OidcUtils.encryptJson(json, configContext.getStateEncryptionKey());
} catch (Exception ex) {
LOG.errorf("State containing the code verifier can not be encrypted: %s", ex.getMessage());
throw new AuthenticationCompletionException(ex);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ public class CodeAuthenticationStateBean {

private String codeVerifier;

private String nonce;

public String getRestorePath() {
return restorePath;
}
Expand All @@ -22,8 +24,16 @@ public void setCodeVerifier(String codeVerifier) {
this.codeVerifier = codeVerifier;
}

public String getNonce() {
return nonce;
}

public void setNonce(String nonce) {
this.nonce = nonce;
}

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

}
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
import io.quarkus.oidc.OIDCException;
import io.quarkus.oidc.OidcTenantConfig;
import io.quarkus.oidc.common.runtime.OidcCommonUtils;
import io.quarkus.runtime.configuration.ConfigurationException;

public class TenantConfigContext {
private static final Logger LOG = Logger.getLogger(TenantConfigContext.class);
Expand All @@ -28,7 +29,7 @@ public class TenantConfigContext {
/**
* PKCE Secret Key
*/
private final SecretKey pkceSecretKey;
private final SecretKey stateSecretKey;

/**
* Token Encryption Secret Key
Expand All @@ -47,39 +48,47 @@ public TenantConfigContext(OidcProvider client, OidcTenantConfig config, boolean
this.ready = ready;

boolean isService = OidcUtils.isServiceApp(config);
pkceSecretKey = !isService && provider != null && provider.client != null ? createPkceSecretKey(config) : null;
stateSecretKey = !isService && provider != null && provider.client != null ? createStateSecretKey(config) : null;
tokenEncSecretKey = !isService && provider != null && provider.client != null ? createTokenEncSecretKey(config) : null;
}

private static SecretKey createPkceSecretKey(OidcTenantConfig config) {
if (config.authentication.pkceRequired.orElse(false)) {
String pkceSecret = null;
if (config.authentication.pkceSecret.isPresent()) {
pkceSecret = config.authentication.pkceSecret.get();
} else {
LOG.debug("'quarkus.oidc.token-state-manager.encryption-secret' is not configured, "
private static SecretKey createStateSecretKey(OidcTenantConfig config) {
if (config.authentication.pkceRequired.orElse(false) || config.authentication.nonceRequired) {
String stateSecret = null;
if (config.authentication.pkceSecret.isPresent() && config.authentication.getStateSecret().isPresent()) {
throw new ConfigurationException(
"Both 'quarkus.oidc.authentication.state-secret' and 'quarkus.oidc.authentication.pkce-secret' are configured");
}
if (config.authentication.getStateSecret().isPresent()) {
stateSecret = config.authentication.getStateSecret().get();
} else if (config.authentication.pkceSecret.isPresent()) {
stateSecret = config.authentication.pkceSecret.get();
}

if (stateSecret == null) {
LOG.debug("'quarkus.oidc.authentication.state-secret' is not configured, "
+ "trying to use the configured client secret");
String possiblePkceSecret = fallbackToClientSecret(config);
if (possiblePkceSecret != null && possiblePkceSecret.length() < 32) {
LOG.debug("Client secret is less than 32 characters long, the pkce secret will be generated");
} else {
pkceSecret = possiblePkceSecret;
stateSecret = possiblePkceSecret;
}
}
try {
if (pkceSecret == null) {
LOG.debug("Secret key for encrypting PKCE code verifier is missing, auto-generating it");
if (stateSecret == null) {
LOG.debug("Secret key for encrypting state cookie is missing, auto-generating it");
SecretKey key = generateSecretKey();
return key;
}
byte[] secretBytes = pkceSecret.getBytes(StandardCharsets.UTF_8);
byte[] secretBytes = stateSecret.getBytes(StandardCharsets.UTF_8);
if (secretBytes.length < 32) {
String errorMessage = "Secret key for encrypting PKCE code verifier in a state cookie should be at least 32 characters long"
String errorMessage = "Secret key for encrypting the state cookie should be at least 32 characters long"
+ " for the strongest state cookie encryption to be produced."
+ " Please update 'quarkus.oidc.authentication.pkce-secret' or update the configured client secret.";
+ " Please update 'quarkus.oidc.authentication.state-secret' or update the configured client secret.";
if (secretBytes.length < 16) {
throw new RuntimeException(
"Secret key for encrypting PKCE code verifier is less than 32 characters long");
throw new ConfigurationException(
"Secret key for encrypting the state cookie is less than 16 characters long");
} else {
LOG.debug(errorMessage);
}
Expand Down Expand Up @@ -149,8 +158,8 @@ public OidcTenantConfig getOidcTenantConfig() {
return oidcConfig;
}

public SecretKey getPkceSecretKey() {
return pkceSecretKey;
public SecretKey getStateEncryptionKey() {
return stateSecretKey;
}

public SecretKey getTokenEncSecretKey() {
Expand Down

0 comments on commit 095f4fe

Please sign in to comment.