Skip to content

Commit 7cf4b35

Browse files
authored
Merge pull request #35039 from sberyozkin/oidc_nonce_check
Support OIDC authorization code flow nonce
2 parents 0c751fe + 9ea734d commit 7cf4b35

File tree

12 files changed

+285
-45
lines changed

12 files changed

+285
-45
lines changed

docs/src/main/asciidoc/security-oidc-code-flow-authentication.adoc

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -469,15 +469,15 @@ link:https://datatracker.ietf.org/doc/html/rfc7636[Proof Key for Code Exchange]
469469
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.
470470
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.
471471

472-
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:
472+
You can enable `PKCE` for your OIDC `web-app` endpoint with a `quarkus.oidc.authentication.pkce-required` property and a 32-character secret which is required to encrypt the PKCE code verifier in the state cookie, as shown in the following example:
473473

474474
[source, properties]
475475
----
476476
quarkus.oidc.authentication.pkce-required=true
477-
quarkus.oidc.authentication.pkce-secret=eUk1p7UB3nFiXZGUXi0uph1Y9p34YhBU
477+
quarkus.oidc.authentication.state-secret=eUk1p7UB3nFiXZGUXi0uph1Y9p34YhBU
478478
----
479479

480-
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.
480+
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.
481481

482482
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.
483483
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.

docs/src/main/asciidoc/security-openid-connect-providers.adoc

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -386,7 +386,7 @@ Twitter provider requires Proof Key for Code Exchange (PKCE) which is supported
386386
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
387387
generate a secure random secret key for encrypting it.
388388
389-
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
389+
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
390390
note that this secret should be 32 characters long, and an error will be reported if it is less than 16 characters long.
391391
====
392392

extensions/oidc-common/runtime/src/main/java/io/quarkus/oidc/common/runtime/OidcConstants.java

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,4 +73,5 @@ public final class OidcConstants {
7373
public static final String ID_TOKEN_SID_CLAIM = "sid";
7474

7575
public static final String OPENID_SCOPE = "openid";
76+
public static final String NONCE = "nonce";
7677
}

extensions/oidc/deployment/src/test/java/io/quarkus/oidc/test/CodeFlowDevModeTestCase.java

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -181,14 +181,14 @@ public void run() throws Throwable {
181181
String line = null;
182182
while ((line = reader.readLine()) != null) {
183183
if (line.contains(
184-
"Secret key for encrypting PKCE code verifier is missing, auto-generating it")) {
184+
"Secret key for encrypting state cookie is missing, auto-generating it")) {
185185
checkPassed.set(true);
186186
}
187187
}
188188
}
189189
}
190190
});
191-
assertTrue(checkPassed.get(), "Can not confirm Secret key for encrypting PKCE code verifier has been generated");
191+
assertTrue(checkPassed.get(), "Can not confirm Secret key for encrypting state cookie has been generated");
192192
}
193193

194194
}

extensions/oidc/runtime/src/main/java/io/quarkus/oidc/OidcTenantConfig.java

Lines changed: 40 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -781,6 +781,15 @@ public enum ResponseMode {
781781
@ConfigItem
782782
public Optional<List<String>> scopes = Optional.empty();
783783

784+
/**
785+
* Require that ID token includes `nonce` claim which must match `nonce` authentication request query parameter.
786+
* Enabling this property can help mitigate replay attacks.
787+
* Do not enable this property if your OpenId Connect provider does not support setting `nonce` in ID token
788+
* or if you work with OAuth2 provider such as `GitHub` which does not issue ID tokens.
789+
*/
790+
@ConfigItem(defaultValue = "false")
791+
public boolean nonceRequired = false;
792+
784793
/**
785794
* Add the 'openid' scope automatically to the list of scopes. This is required for OpenId Connect providers
786795
* but will not work for OAuth2 providers such as Twitter OAuth2 which does not accept that scope and throws an error.
@@ -945,19 +954,30 @@ public enum ResponseMode {
945954
/**
946955
* Secret which will be used to encrypt a Proof Key for Code Exchange (PKCE) code verifier in the code flow state.
947956
* This secret should be at least 32 characters long.
957+
*
958+
* @deprecated Use {@link #stateSecret} property instead.
959+
*/
960+
@ConfigItem
961+
@Deprecated(forRemoval = true)
962+
public Optional<String> pkceSecret = Optional.empty();
963+
964+
/**
965+
* Secret which will be used to encrypt Proof Key for Code Exchange (PKCE) code verifier and/or nonce in the code flow
966+
* state.
967+
* This secret should be at least 32 characters long.
948968
* <p/>
949969
* If this secret is not set, the client secret configured with
950970
* either `quarkus.oidc.credentials.secret` or `quarkus.oidc.credentials.client-secret.value` will be checked.
951971
* Finally, `quarkus.oidc.credentials.jwt.secret` which can be used for `client_jwt_secret` authentication will be
952-
* checked. Client secret will not be used as a PKCE code verifier encryption secret if it is less than 32 characters
972+
* checked. Client secret will not be used as a state encryption secret if it is less than 32 characters
953973
* long.
954974
* </p>
955975
* The secret will be auto-generated if it remains uninitialized after checking all of these properties.
956976
* <p/>
957977
* Error will be reported if the secret length is less than 16 characters.
958978
*/
959979
@ConfigItem
960-
public Optional<String> pkceSecret = Optional.empty();
980+
public Optional<String> stateSecret = Optional.empty();
961981

962982
public Optional<Duration> getInternalIdTokenLifespan() {
963983
return internalIdTokenLifespan;
@@ -975,10 +995,12 @@ public void setPkceRequired(boolean pkceRequired) {
975995
this.pkceRequired = Optional.of(pkceRequired);
976996
}
977997

998+
@Deprecated(forRemoval = true)
978999
public Optional<String> getPkceSecret() {
9791000
return pkceSecret;
9801001
}
9811002

1003+
@Deprecated(forRemoval = true)
9821004
public void setPkceSecret(String pkceSecret) {
9831005
this.pkceSecret = Optional.of(pkceSecret);
9841006
}
@@ -1158,6 +1180,22 @@ public boolean isAllowMultipleCodeFlows() {
11581180
public void setAllowMultipleCodeFlows(boolean allowMultipleCodeFlows) {
11591181
this.allowMultipleCodeFlows = allowMultipleCodeFlows;
11601182
}
1183+
1184+
public boolean isNonceRequired() {
1185+
return nonceRequired;
1186+
}
1187+
1188+
public void setNonceRequired(boolean nonceRequired) {
1189+
this.nonceRequired = nonceRequired;
1190+
}
1191+
1192+
public Optional<String> getStateSecret() {
1193+
return stateSecret;
1194+
}
1195+
1196+
public void setStateSecret(Optional<String> stateSecret) {
1197+
this.stateSecret = stateSecret;
1198+
}
11611199
}
11621200

11631201
/**

extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/CodeAuthenticationMechanism.java

Lines changed: 44 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -623,9 +623,12 @@ && isRedirectFromProvider(context, configContext)) {
623623
PkceStateBean pkceStateBean = createPkceStateBean(configContext);
624624

625625
// state
626+
String nonce = configContext.oidcConfig.authentication.nonceRequired ? UUID.randomUUID().toString()
627+
: null;
628+
626629
codeFlowParams.append(AMP).append(OidcConstants.CODE_FLOW_STATE).append(EQ)
627630
.append(generateCodeFlowState(context, configContext, redirectPath, requestQueryParams,
628-
pkceStateBean != null ? pkceStateBean.getCodeVerifier() : null));
631+
(pkceStateBean != null ? pkceStateBean.getCodeVerifier() : null), nonce));
629632

630633
if (pkceStateBean != null) {
631634
codeFlowParams
@@ -636,6 +639,10 @@ && isRedirectFromProvider(context, configContext)) {
636639
.append(OidcConstants.PKCE_CODE_CHALLENGE_S256);
637640
}
638641

642+
if (nonce != null) {
643+
codeFlowParams.append(AMP).append(OidcConstants.NONCE).append(EQ).append(nonce);
644+
}
645+
639646
// extra redirect parameters, see https://openid.net/specs/openid-connect-core-1_0.html#AuthRequests
640647
addExtraParamsToUri(codeFlowParams, configContext.oidcConfig.authentication.getExtraParams());
641648

@@ -739,6 +746,9 @@ public Uni<SecurityIdentity> apply(final AuthorizationCodeTokens tokens, final T
739746
internalIdToken = true;
740747
}
741748
} else {
749+
if (!verifyNonce(configContext.oidcConfig, stateBean, tokens.getIdToken())) {
750+
return Uni.createFrom().failure(new AuthenticationCompletionException());
751+
}
742752
internalIdToken = false;
743753
}
744754

@@ -814,6 +824,21 @@ public Throwable apply(Throwable tInner) {
814824
});
815825
}
816826

827+
private static boolean verifyNonce(OidcTenantConfig oidcConfig, CodeAuthenticationStateBean stateBean, String idToken) {
828+
if (oidcConfig.authentication.nonceRequired) {
829+
if (stateBean != null && stateBean.getNonce() != null) {
830+
JsonObject idTokenClaims = OidcUtils.decodeJwtContent(idToken);
831+
if (stateBean.getNonce().equals(idTokenClaims.getString(OidcConstants.NONCE))) {
832+
return true;
833+
}
834+
}
835+
LOG.errorf("ID token 'nonce' does not match the authentication request 'nonce' value");
836+
return false;
837+
} else {
838+
return true;
839+
}
840+
}
841+
817842
private static Object errorMessage(Throwable t) {
818843
return t.getCause() != null ? t.getCause().getMessage() : t.getMessage();
819844
}
@@ -822,21 +847,24 @@ private CodeAuthenticationStateBean getCodeAuthenticationBean(String[] parsedSta
822847
TenantConfigContext configContext) {
823848
if (parsedStateCookieValue.length == 2) {
824849
CodeAuthenticationStateBean bean = new CodeAuthenticationStateBean();
825-
if (!configContext.oidcConfig.authentication.pkceRequired.orElse(false)) {
850+
Authentication authentication = configContext.oidcConfig.authentication;
851+
boolean pkceRequired = authentication.pkceRequired.orElse(false);
852+
if (!pkceRequired && !authentication.nonceRequired) {
826853
bean.setRestorePath(parsedStateCookieValue[1]);
827854
return bean;
828855
}
829856

830857
JsonObject json = null;
831858
try {
832-
json = OidcUtils.decryptJson(parsedStateCookieValue[1], configContext.getPkceSecretKey());
859+
json = OidcUtils.decryptJson(parsedStateCookieValue[1], configContext.getStateEncryptionKey());
833860
} catch (Exception ex) {
834861
LOG.errorf("State cookie value can not be decrypted for the %s tenant",
835862
configContext.oidcConfig.tenantId.get());
836863
throw new AuthenticationCompletionException(ex);
837864
}
838865
bean.setRestorePath(json.getString(STATE_COOKIE_RESTORE_PATH));
839866
bean.setCodeVerifier(json.getString(OidcConstants.PKCE_CODE_VERIFIER));
867+
bean.setNonce(json.getString(OidcConstants.NONCE));
840868
return bean;
841869
}
842870
return null;
@@ -943,12 +971,13 @@ private String getRedirectPath(OidcTenantConfig oidcConfig, RoutingContext conte
943971
}
944972

945973
private String generateCodeFlowState(RoutingContext context, TenantConfigContext configContext,
946-
String redirectPath, MultiMap requestQueryWithoutForwardedParams, String pkceCodeVerifier) {
974+
String redirectPath, MultiMap requestQueryWithoutForwardedParams, String pkceCodeVerifier, String nonce) {
947975
String uuid = UUID.randomUUID().toString();
948976
String cookieValue = uuid;
949977

950-
boolean restorePath = isRestorePath(configContext.oidcConfig.getAuthentication());
951-
if (restorePath || pkceCodeVerifier != null) {
978+
Authentication authentication = configContext.oidcConfig.getAuthentication();
979+
boolean restorePath = isRestorePath(authentication);
980+
if (restorePath || pkceCodeVerifier != null || nonce != null) {
952981
CodeAuthenticationStateBean extraStateValue = new CodeAuthenticationStateBean();
953982
if (restorePath) {
954983
String requestQuery = context.request().query();
@@ -978,6 +1007,7 @@ private String generateCodeFlowState(RoutingContext context, TenantConfigContext
9781007
}
9791008
}
9801009
extraStateValue.setCodeVerifier(pkceCodeVerifier);
1010+
extraStateValue.setNonce(nonce);
9811011
if (!extraStateValue.isEmpty()) {
9821012
cookieValue += (COOKIE_DELIM + encodeExtraStateValue(extraStateValue, configContext));
9831013
}
@@ -997,14 +1027,19 @@ private boolean isRestorePath(Authentication auth) {
9971027
}
9981028

9991029
private String encodeExtraStateValue(CodeAuthenticationStateBean extraStateValue, TenantConfigContext configContext) {
1000-
if (extraStateValue.getCodeVerifier() != null) {
1030+
if (extraStateValue.getCodeVerifier() != null || extraStateValue.getNonce() != null) {
10011031
JsonObject json = new JsonObject();
1002-
json.put(OidcConstants.PKCE_CODE_VERIFIER, extraStateValue.getCodeVerifier());
1032+
if (extraStateValue.getCodeVerifier() != null) {
1033+
json.put(OidcConstants.PKCE_CODE_VERIFIER, extraStateValue.getCodeVerifier());
1034+
}
1035+
if (extraStateValue.getNonce() != null) {
1036+
json.put(OidcConstants.NONCE, extraStateValue.getNonce());
1037+
}
10031038
if (extraStateValue.getRestorePath() != null) {
10041039
json.put(STATE_COOKIE_RESTORE_PATH, extraStateValue.getRestorePath());
10051040
}
10061041
try {
1007-
return OidcUtils.encryptJson(json, configContext.getPkceSecretKey());
1042+
return OidcUtils.encryptJson(json, configContext.getStateEncryptionKey());
10081043
} catch (Exception ex) {
10091044
LOG.errorf("State containing the code verifier can not be encrypted: %s", ex.getMessage());
10101045
throw new AuthenticationCompletionException(ex);

extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/CodeAuthenticationStateBean.java

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ public class CodeAuthenticationStateBean {
66

77
private String codeVerifier;
88

9+
private String nonce;
10+
911
public String getRestorePath() {
1012
return restorePath;
1113
}
@@ -22,8 +24,16 @@ public void setCodeVerifier(String codeVerifier) {
2224
this.codeVerifier = codeVerifier;
2325
}
2426

27+
public String getNonce() {
28+
return nonce;
29+
}
30+
31+
public void setNonce(String nonce) {
32+
this.nonce = nonce;
33+
}
34+
2535
public boolean isEmpty() {
26-
return this.restorePath == null && this.codeVerifier == null;
36+
return this.restorePath == null && this.codeVerifier == null && nonce == null;
2737
}
2838

2939
}

0 commit comments

Comments
 (0)