Skip to content

Commit a0693e6

Browse files
authored
Merge pull request #1148 from bobcallaway/add_nonce
add nonce parameter to OIDC flow
2 parents dfd6ba5 + 4935f7a commit a0693e6

3 files changed

Lines changed: 167 additions & 1 deletion

File tree

sigstore-java/src/main/java/dev/sigstore/oidc/client/WebOidcClient.java

Lines changed: 56 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
package dev.sigstore.oidc.client;
1717

1818
import com.google.api.client.auth.oauth2.AuthorizationCodeFlow;
19+
import com.google.api.client.auth.oauth2.AuthorizationCodeRequestUrl;
1920
import com.google.api.client.auth.oauth2.BearerToken;
2021
import com.google.api.client.auth.oauth2.ClientParametersAuthentication;
2122
import com.google.api.client.auth.openidconnect.IdToken;
@@ -38,7 +39,9 @@
3839
import dev.sigstore.trustroot.Service;
3940
import java.io.IOException;
4041
import java.net.URI;
42+
import java.security.SecureRandom;
4143
import java.util.Arrays;
44+
import java.util.Base64;
4245
import java.util.Locale;
4346
import java.util.Map;
4447
import java.util.logging.Logger;
@@ -153,6 +156,10 @@ public OidcToken getIDToken(Map<String, String> env) throws OidcException {
153156
throw new OidcException(
154157
"ioexception obtaining and parsing oidc configuration for " + issuer, e);
155158
}
159+
160+
// Generate a cryptographically secure nonce for replay attack prevention
161+
String nonce = generateNonce();
162+
156163
AuthorizationCodeFlow.Builder flowBuilder =
157164
new AuthorizationCodeFlow.Builder(
158165
BearerToken.authorizationHeaderAccessMethod(),
@@ -171,9 +178,12 @@ public OidcToken getIDToken(Map<String, String> env) throws OidcException {
171178
memStoreFactory
172179
.getDataStore("user")
173180
.set(ID_TOKEN_KEY, tokenResponse.get(ID_TOKEN_KEY).toString()));
181+
182+
// Use custom flow that injects nonce into authorization URL
183+
NonceAuthorizationCodeFlow flow = new NonceAuthorizationCodeFlow(flowBuilder, nonce);
174184
AuthorizationCodeInstalledApp app =
175185
new AuthorizationCodeInstalledApp(
176-
flowBuilder.build(), new LocalServerReceiver(), browserHandler::openBrowser);
186+
flow, new LocalServerReceiver(), browserHandler::openBrowser);
177187

178188
String idTokenString = null;
179189
IdToken parsedIdToken = null;
@@ -189,6 +199,16 @@ public OidcToken getIDToken(Map<String, String> env) throws OidcException {
189199
if (!idTokenVerifier.verifyOrThrow(parsedIdToken)) {
190200
throw new OidcException("id token could not be verified");
191201
}
202+
203+
// Verify that the nonce in the ID token matches the one we sent
204+
Object tokenNonce = parsedIdToken.getPayload().get("nonce");
205+
if (tokenNonce == null) {
206+
throw new OidcException("id token is missing required nonce claim");
207+
}
208+
if (!nonce.equals(tokenNonce.toString())) {
209+
throw new OidcException(
210+
"nonce in id token does not match expected value - possible replay attack");
211+
}
192212
} catch (IOException e) {
193213
// TODO: maybe a more descriptive exception message
194214
throw new OidcException("ioexception during oidc handshake", e);
@@ -254,4 +274,39 @@ public interface BrowserHandler {
254274
/** Opens a browser to allow a user to complete the oauth browser workflow. */
255275
void openBrowser(String url) throws IOException;
256276
}
277+
278+
/**
279+
* Generates a cryptographically secure random nonce for OIDC authentication. The nonce is used to
280+
* prevent replay attacks by binding the ID token to the authentication request.
281+
*
282+
* @return a URL-safe base64-encoded random string
283+
*/
284+
private static String generateNonce() {
285+
SecureRandom secureRandom = new SecureRandom();
286+
byte[] nonceBytes = new byte[32];
287+
secureRandom.nextBytes(nonceBytes);
288+
return Base64.getUrlEncoder().withoutPadding().encodeToString(nonceBytes);
289+
}
290+
291+
/**
292+
* Custom AuthorizationCodeFlow that adds a nonce parameter to the authorization URL. This is
293+
* required for OpenID Connect to prevent replay attacks.
294+
*/
295+
private static class NonceAuthorizationCodeFlow extends AuthorizationCodeFlow {
296+
private final String nonce;
297+
298+
NonceAuthorizationCodeFlow(AuthorizationCodeFlow.Builder builder, String nonce) {
299+
super(builder);
300+
this.nonce = nonce;
301+
}
302+
303+
@Override
304+
public AuthorizationCodeRequestUrl newAuthorizationUrl() {
305+
return super.newAuthorizationUrl().set("nonce", nonce);
306+
}
307+
308+
String getNonce() {
309+
return nonce;
310+
}
311+
}
257312
}
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
/*
2+
* Copyright 2026 The Sigstore Authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
package dev.sigstore.oidc.client;
17+
18+
import com.gargoylesoftware.htmlunit.WebClient;
19+
import com.google.common.io.Resources;
20+
import dev.sigstore.trustroot.Service;
21+
import java.io.IOException;
22+
import java.nio.charset.StandardCharsets;
23+
import java.util.Map;
24+
import no.nav.security.mock.oauth2.MockOAuth2Server;
25+
import no.nav.security.mock.oauth2.OAuth2Config;
26+
import org.junit.jupiter.api.AfterEach;
27+
import org.junit.jupiter.api.Assertions;
28+
import org.junit.jupiter.api.Test;
29+
30+
public class WebOidcClientNonceTest {
31+
32+
private MockOAuth2Server server;
33+
34+
@AfterEach
35+
void teardown() throws IOException {
36+
if (server != null) {
37+
server.shutdown();
38+
}
39+
}
40+
41+
@Test
42+
void testNonceVerificationSuccess() throws Exception {
43+
String config =
44+
Resources.toString(
45+
Resources.getResource("dev/sigstore/oidc/server/config.json"), StandardCharsets.UTF_8);
46+
server = new MockOAuth2Server(OAuth2Config.Companion.fromJson(config));
47+
server.start();
48+
49+
try (var webClient = new WebClient()) {
50+
var oidcClient =
51+
WebOidcClient.builder()
52+
.setIssuer(Service.of(server.issuerUrl("test-default").uri(), 1))
53+
.setBrowser(webClient::getPage)
54+
.build();
55+
56+
var token = oidcClient.getIDToken(Map.of());
57+
Assertions.assertNotNull(token.getIdToken());
58+
}
59+
}
60+
61+
@Test
62+
void testNonceVerificationFailure_MismatchedNonce() throws Exception {
63+
String config =
64+
Resources.toString(
65+
Resources.getResource("dev/sigstore/oidc/server/config-bad-nonce.json"),
66+
StandardCharsets.UTF_8);
67+
server = new MockOAuth2Server(OAuth2Config.Companion.fromJson(config));
68+
server.start();
69+
70+
try (var webClient = new WebClient()) {
71+
var oidcClient =
72+
WebOidcClient.builder()
73+
.setIssuer(Service.of(server.issuerUrl("test-default").uri(), 1))
74+
.setBrowser(webClient::getPage)
75+
.build();
76+
77+
OidcException exception =
78+
Assertions.assertThrows(
79+
OidcException.class,
80+
() -> {
81+
oidcClient.getIDToken(Map.of());
82+
});
83+
Assertions.assertTrue(exception.getMessage().contains("nonce in id token does not match"));
84+
}
85+
}
86+
}
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
{
2+
"tokenProvider" : {
3+
"keyProvider" : {
4+
"initialKeys" : "{\"alg\": \"ES256\",\"kty\": \"EC\",\"d\": \"o9INzHyU_I97djF36YQRpHCJxFTgDTbS1OtwUnHc34U\",\"use\": \"sig\",\"crv\": \"P-256\",\"kid\": \"test-default\",\"x\": \"umybCYzE-VX_UAIJaX3wc-GTOgB7WDp7A3JJAKW_hqU\",\"y\": \"m_sCzuMjiBSQ7At9yNktMQvE1cCKq68jO7wnRczwKw8\"}",
5+
"algorithm" : "ES256"
6+
}
7+
},
8+
"tokenCallbacks" : [
9+
{
10+
"issuerId": "test-default",
11+
"tokenExpiry": 120,
12+
"requestMappings": [
13+
{
14+
"requestParam": "scope",
15+
"match": "openid email",
16+
"claims": {
17+
"audience": "sigstore",
18+
"email": "test.person@test.com",
19+
"email_verified": true,
20+
"nonce": "attacker-supplied-nonce"
21+
}
22+
}
23+
]
24+
}]
25+
}

0 commit comments

Comments
 (0)