diff --git a/sigstore-java/src/main/java/dev/sigstore/oidc/client/WebOidcClient.java b/sigstore-java/src/main/java/dev/sigstore/oidc/client/WebOidcClient.java index b0046147e..c1039ba58 100644 --- a/sigstore-java/src/main/java/dev/sigstore/oidc/client/WebOidcClient.java +++ b/sigstore-java/src/main/java/dev/sigstore/oidc/client/WebOidcClient.java @@ -16,6 +16,7 @@ package dev.sigstore.oidc.client; import com.google.api.client.auth.oauth2.AuthorizationCodeFlow; +import com.google.api.client.auth.oauth2.AuthorizationCodeRequestUrl; import com.google.api.client.auth.oauth2.BearerToken; import com.google.api.client.auth.oauth2.ClientParametersAuthentication; import com.google.api.client.auth.openidconnect.IdToken; @@ -38,7 +39,9 @@ import dev.sigstore.trustroot.Service; import java.io.IOException; import java.net.URI; +import java.security.SecureRandom; import java.util.Arrays; +import java.util.Base64; import java.util.Locale; import java.util.Map; import java.util.logging.Logger; @@ -153,6 +156,10 @@ public OidcToken getIDToken(Map env) throws OidcException { throw new OidcException( "ioexception obtaining and parsing oidc configuration for " + issuer, e); } + + // Generate a cryptographically secure nonce for replay attack prevention + String nonce = generateNonce(); + AuthorizationCodeFlow.Builder flowBuilder = new AuthorizationCodeFlow.Builder( BearerToken.authorizationHeaderAccessMethod(), @@ -171,9 +178,12 @@ public OidcToken getIDToken(Map env) throws OidcException { memStoreFactory .getDataStore("user") .set(ID_TOKEN_KEY, tokenResponse.get(ID_TOKEN_KEY).toString())); + + // Use custom flow that injects nonce into authorization URL + NonceAuthorizationCodeFlow flow = new NonceAuthorizationCodeFlow(flowBuilder, nonce); AuthorizationCodeInstalledApp app = new AuthorizationCodeInstalledApp( - flowBuilder.build(), new LocalServerReceiver(), browserHandler::openBrowser); + flow, new LocalServerReceiver(), browserHandler::openBrowser); String idTokenString = null; IdToken parsedIdToken = null; @@ -189,6 +199,16 @@ public OidcToken getIDToken(Map env) throws OidcException { if (!idTokenVerifier.verifyOrThrow(parsedIdToken)) { throw new OidcException("id token could not be verified"); } + + // Verify that the nonce in the ID token matches the one we sent + Object tokenNonce = parsedIdToken.getPayload().get("nonce"); + if (tokenNonce == null) { + throw new OidcException("id token is missing required nonce claim"); + } + if (!nonce.equals(tokenNonce.toString())) { + throw new OidcException( + "nonce in id token does not match expected value - possible replay attack"); + } } catch (IOException e) { // TODO: maybe a more descriptive exception message throw new OidcException("ioexception during oidc handshake", e); @@ -254,4 +274,39 @@ public interface BrowserHandler { /** Opens a browser to allow a user to complete the oauth browser workflow. */ void openBrowser(String url) throws IOException; } + + /** + * Generates a cryptographically secure random nonce for OIDC authentication. The nonce is used to + * prevent replay attacks by binding the ID token to the authentication request. + * + * @return a URL-safe base64-encoded random string + */ + private static String generateNonce() { + SecureRandom secureRandom = new SecureRandom(); + byte[] nonceBytes = new byte[32]; + secureRandom.nextBytes(nonceBytes); + return Base64.getUrlEncoder().withoutPadding().encodeToString(nonceBytes); + } + + /** + * Custom AuthorizationCodeFlow that adds a nonce parameter to the authorization URL. This is + * required for OpenID Connect to prevent replay attacks. + */ + private static class NonceAuthorizationCodeFlow extends AuthorizationCodeFlow { + private final String nonce; + + NonceAuthorizationCodeFlow(AuthorizationCodeFlow.Builder builder, String nonce) { + super(builder); + this.nonce = nonce; + } + + @Override + public AuthorizationCodeRequestUrl newAuthorizationUrl() { + return super.newAuthorizationUrl().set("nonce", nonce); + } + + String getNonce() { + return nonce; + } + } } diff --git a/sigstore-java/src/test/java/dev/sigstore/oidc/client/WebOidcClientNonceTest.java b/sigstore-java/src/test/java/dev/sigstore/oidc/client/WebOidcClientNonceTest.java new file mode 100644 index 000000000..5252fd1fc --- /dev/null +++ b/sigstore-java/src/test/java/dev/sigstore/oidc/client/WebOidcClientNonceTest.java @@ -0,0 +1,86 @@ +/* + * Copyright 2026 The Sigstore Authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package dev.sigstore.oidc.client; + +import com.gargoylesoftware.htmlunit.WebClient; +import com.google.common.io.Resources; +import dev.sigstore.trustroot.Service; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.Map; +import no.nav.security.mock.oauth2.MockOAuth2Server; +import no.nav.security.mock.oauth2.OAuth2Config; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +public class WebOidcClientNonceTest { + + private MockOAuth2Server server; + + @AfterEach + void teardown() throws IOException { + if (server != null) { + server.shutdown(); + } + } + + @Test + void testNonceVerificationSuccess() throws Exception { + String config = + Resources.toString( + Resources.getResource("dev/sigstore/oidc/server/config.json"), StandardCharsets.UTF_8); + server = new MockOAuth2Server(OAuth2Config.Companion.fromJson(config)); + server.start(); + + try (var webClient = new WebClient()) { + var oidcClient = + WebOidcClient.builder() + .setIssuer(Service.of(server.issuerUrl("test-default").uri(), 1)) + .setBrowser(webClient::getPage) + .build(); + + var token = oidcClient.getIDToken(Map.of()); + Assertions.assertNotNull(token.getIdToken()); + } + } + + @Test + void testNonceVerificationFailure_MismatchedNonce() throws Exception { + String config = + Resources.toString( + Resources.getResource("dev/sigstore/oidc/server/config-bad-nonce.json"), + StandardCharsets.UTF_8); + server = new MockOAuth2Server(OAuth2Config.Companion.fromJson(config)); + server.start(); + + try (var webClient = new WebClient()) { + var oidcClient = + WebOidcClient.builder() + .setIssuer(Service.of(server.issuerUrl("test-default").uri(), 1)) + .setBrowser(webClient::getPage) + .build(); + + OidcException exception = + Assertions.assertThrows( + OidcException.class, + () -> { + oidcClient.getIDToken(Map.of()); + }); + Assertions.assertTrue(exception.getMessage().contains("nonce in id token does not match")); + } + } +} diff --git a/sigstore-java/src/test/resources/dev/sigstore/oidc/server/config-bad-nonce.json b/sigstore-java/src/test/resources/dev/sigstore/oidc/server/config-bad-nonce.json new file mode 100644 index 000000000..d8ff2a693 --- /dev/null +++ b/sigstore-java/src/test/resources/dev/sigstore/oidc/server/config-bad-nonce.json @@ -0,0 +1,25 @@ +{ + "tokenProvider" : { + "keyProvider" : { + "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\"}", + "algorithm" : "ES256" + } + }, + "tokenCallbacks" : [ + { + "issuerId": "test-default", + "tokenExpiry": 120, + "requestMappings": [ + { + "requestParam": "scope", + "match": "openid email", + "claims": { + "audience": "sigstore", + "email": "test.person@test.com", + "email_verified": true, + "nonce": "attacker-supplied-nonce" + } + } + ] + }] +} \ No newline at end of file