Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -153,6 +156,10 @@ public OidcToken getIDToken(Map<String, String> 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(),
Expand All @@ -171,9 +178,12 @@ public OidcToken getIDToken(Map<String, String> 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;
Expand All @@ -189,6 +199,16 @@ public OidcToken getIDToken(Map<String, String> 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);
Expand Down Expand Up @@ -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;
}
}
}
Original file line number Diff line number Diff line change
@@ -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"));
}
}
}
Original file line number Diff line number Diff line change
@@ -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": "[email protected]",
"email_verified": true,
"nonce": "attacker-supplied-nonce"
}
}
]
}]
}
Loading