Skip to content
Merged
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 @@ -12,7 +12,11 @@ import com.openai.errors.OpenAIIoException
import com.openai.errors.OpenAIRetryableException
import com.openai.errors.UnexpectedStatusCodeException
import java.io.IOException
import java.security.cert.CertPathBuilderException
import java.security.cert.CertPathValidatorException
import java.security.cert.CertificateException
import java.time.Duration
import java.util.IdentityHashMap
import java.util.Locale
import java.util.concurrent.CompletableFuture
import java.util.concurrent.CompletionException
Expand All @@ -23,13 +27,15 @@ import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicBoolean
import java.util.concurrent.atomic.AtomicLong
import java.util.concurrent.atomic.AtomicReference
import javax.net.ssl.SSLException

internal const val X509_API_BASE_URL = "https://mtls.api.openai.com/v1"

internal class X509ClientConfiguration
private constructor(
private val identity: X509WorkloadIdentity,
private val bindTransport: (Timeout) -> BoundX509Transport,
private val authenticatorNanoTime: () -> Long,
private val installTransport:
(ClientOptions.Builder, OkHttpClient, HttpRequestAttemptAuthenticator) -> ClientOptions,
) {
Expand All @@ -39,9 +45,12 @@ private constructor(
identity: X509WorkloadIdentity,
bindTransport: (Timeout) -> BoundX509Transport,
) =
X509ClientConfiguration(identity, bindTransport) { options, client, authenticator ->
options.buildWithFixedBearerTransport(client, authenticator)
}
X509ClientConfiguration(
identity,
bindTransport,
System::nanoTime,
ClientOptions.Builder::buildWithFixedBearerTransport,
)

@JvmSynthetic
internal fun createForTest(
Expand All @@ -51,7 +60,20 @@ private constructor(
(
ClientOptions.Builder, OkHttpClient, HttpRequestAttemptAuthenticator,
) -> ClientOptions,
) = X509ClientConfiguration(identity, bindTransport, installTransport)
) = X509ClientConfiguration(identity, bindTransport, System::nanoTime, installTransport)

@JvmSynthetic
internal fun createWithNanoTimeForTest(
identity: X509WorkloadIdentity,
bindTransport: (Timeout) -> BoundX509Transport,
nanoTime: () -> Long,
) =
X509ClientConfiguration(
identity,
bindTransport,
nanoTime,
ClientOptions.Builder::buildWithFixedBearerTransport,
)
}

@JvmSynthetic
Expand All @@ -64,7 +86,7 @@ private constructor(
val transport = bindTransport(clientOptions.timeout())
val authenticator =
try {
X509AttemptAuthenticator(identity, transport.exchangeClient)
X509AttemptAuthenticator(identity, transport.exchangeClient, authenticatorNanoTime)
} catch (error: Throwable) {
closeAfterFailure(error, transport::close)
throw error
Expand Down Expand Up @@ -100,10 +122,11 @@ private class X509AttemptAuthenticator(
constructor(
identity: X509WorkloadIdentity,
exchangeClient: OkHttpClient,
nanoTime: () -> Long = System::nanoTime,
) : this(
X509TokenExchange(identity, exchangeClient)::executeAsync,
exchangeClient::close,
System::nanoTime,
nanoTime,
{},
{},
{},
Expand Down Expand Up @@ -541,8 +564,9 @@ private class X509AttemptAuthenticator(
fun unchecked(error: Throwable): RuntimeException =
unwrap(error).let { if (it is RuntimeException) it else OpenAIIoException(cause = it) }

fun isTransient(error: Throwable?): Boolean =
when (val cause = unwrap(error)) {
fun isTransient(error: Throwable?): Boolean {
if (hasPermanentTlsFailure(error)) return false
return when (val cause = unwrap(error)) {
is IOException,
is OpenAIIoException,
is OpenAIRetryableException -> true
Expand All @@ -555,6 +579,24 @@ private class X509AttemptAuthenticator(
}
else -> false
}
}

fun hasPermanentTlsFailure(error: Throwable?): Boolean {
val seen = IdentityHashMap<Throwable, Unit>()
var cause = error
while (cause != null && seen.put(cause, Unit) == null) {
if (
cause is SSLException ||
cause is CertificateException ||
cause is CertPathBuilderException ||
cause is CertPathValidatorException
) {
return true
}
cause = cause.cause
}
return false
}

val FORBIDDEN_HEADERS =
setOf(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,14 @@ import com.openai.credential.BearerTokenCredential
import com.openai.errors.OpenAIIoException
import com.openai.models.files.FileListParams
import java.net.Proxy
import java.security.cert.CertificateException
import java.security.cert.X509Certificate
import java.time.Duration
import java.util.concurrent.ExecutionException
import java.util.concurrent.Executors
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicLong
import javax.net.ssl.SSLException
import okhttp3.mockwebserver.MockResponse
import okhttp3.mockwebserver.RecordedRequest
import okhttp3.mockwebserver.SocketPolicy
Expand Down Expand Up @@ -252,6 +255,54 @@ internal class OpenAIOkHttpClientX509Test {
}
}

@Test
fun publicSyncAndAsyncClientsRejectCachedBearerAfterIssuerTlsFailure() {
listOf(false, true).forEach { async ->
Fixture().use { fixture ->
val now = AtomicLong()
fixture.authPeer.enqueue(
fixture
.exchangeResponse("cachedtoken")
.setSocketPolicy(SocketPolicy.DISCONNECT_AT_END)
)
fixture.enqueueApiSuccess()
fixture.enqueueApiSuccess()

if (async) {
val client = fixture.asyncBuilder(now::get).maxRetries(0).build()
try {
client.files().list().get(10, TimeUnit.SECONDS)
now.set(Duration.ofSeconds(3_000).toNanos())
fixture.replaceIssuerWithUntrustedCertificate()

val failure =
runCatching { client.files().list().get(10, TimeUnit.SECONDS) }
.exceptionOrNull()
assertThat(failure).isInstanceOf(ExecutionException::class.java)
assertTlsFailure(requireNotNull(failure))
} finally {
client.close()
}
} else {
val client = fixture.syncBuilder(now::get).maxRetries(0).build()
try {
client.files().list()
now.set(Duration.ofSeconds(3_000).toNanos())
fixture.replaceIssuerWithUntrustedCertificate()

val failure = runCatching { client.files().list() }.exceptionOrNull()
assertThat(failure).isInstanceOf(OpenAIIoException::class.java)
assertTlsFailure(requireNotNull(failure))
} finally {
client.close()
}
}

assertThat(fixture.apiPeer.server.requestCount).isEqualTo(2)
}
}
}

@Test
fun onlyPublicJavaConstructionPathIsX509BuilderFactory() {
listOf(OpenAIOkHttpClient.Builder::class.java, OpenAIOkHttpClientAsync.Builder::class.java)
Expand Down Expand Up @@ -644,6 +695,12 @@ internal class OpenAIOkHttpClientX509Test {
assertThat(requireNotNull(request.handshake).peerCertificates.first()).isEqualTo(expected)
}

private fun assertTlsFailure(failure: Throwable) {
assertThat(generateSequence(failure) { it.cause }.toList()).anyMatch {
it is SSLException || it is CertificateException
}
}

private class Fixture : AutoCloseable {
val identity = X509TestIdentity.create("SDK X.509 identity")
val authPeer = X509TestPeer(AUTH_HOST, identity.root.certificate)
Expand Down Expand Up @@ -682,6 +739,24 @@ internal class OpenAIOkHttpClientX509Test {
apiPeer.proxy,
)

fun syncBuilder(nanoTime: () -> Long): OpenAIOkHttpClient.Builder =
OpenAIOkHttpClient.Builder.x509(configuration(nanoTime))

fun asyncBuilder(nanoTime: () -> Long): OpenAIOkHttpClientAsync.Builder =
OpenAIOkHttpClientAsync.Builder.x509(configuration(nanoTime))

fun replaceIssuerWithUntrustedCertificate() {
authPeer.replaceWithUntrustedCertificate()
authPeer.enqueue(exchangeResponse(ACCESS_TOKEN))
}

private fun configuration(nanoTime: () -> Long) =
X509ClientConfiguration.createWithNanoTimeForTest(
workloadIdentity,
{ timeout -> transport.bindForTest(timeout, authPeer.proxy, apiPeer.proxy) },
nanoTime,
)

fun enqueueSuccess() {
authPeer.enqueue(
MockResponse().setHeader("Content-Type", "application/json").setBody(TOKEN_RESPONSE)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,9 @@ import com.openai.core.http.RetryingHttpClient
import com.openai.errors.OpenAIIoException
import com.openai.errors.OpenAIRetryableException
import java.io.ByteArrayInputStream
import java.io.IOException
import java.security.cert.CertPathBuilderException
import java.security.cert.CertificateException
import java.time.Duration
import java.util.concurrent.CompletableFuture
import java.util.concurrent.CountDownLatch
Expand All @@ -23,6 +26,10 @@ import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicInteger
import java.util.concurrent.atomic.AtomicLong
import java.util.concurrent.atomic.AtomicReference
import javax.net.ssl.SSLException
import javax.net.ssl.SSLHandshakeException
import javax.net.ssl.SSLPeerUnverifiedException
import javax.net.ssl.SSLProtocolException
import org.assertj.core.api.Assertions.assertThat
import org.assertj.core.api.Assertions.assertThatThrownBy
import org.junit.jupiter.api.Test
Expand Down Expand Up @@ -445,6 +452,75 @@ internal class X509AttemptAuthenticatorTest {
authenticator.close()
}

@Test
fun syncAndAsyncPermanentTlsRefreshFailureCannotFallbackToCachedBearer() {
val tlsFailures =
listOf<() -> Throwable>(
{
SSLHandshakeException("untrusted issuer certificate").apply {
initCause(CertificateException("certificate path rejected"))
}
},
{ CertPathBuilderException("issuer certificate path could not be built") },
{ SSLPeerUnverifiedException("issuer hostname mismatch") },
{ SSLProtocolException("issuer TLS protocol failure") },
{ SSLException("issuer TLS failure") },
)

tlsFailures.forEach { tlsFailure ->
listOf(false, true).forEach { async ->
val now = AtomicLong()
val permanentFailure =
OpenAIIoException(
"issuer exchange failed",
IOException("transport wrapper", tlsFailure()),
)
val exchanges =
ArrayDeque(
listOf(
CompletableFuture.completedFuture(
X509AccessToken("cachedtoken", Duration.ofMillis(500))
),
CompletableFuture<X509AccessToken>().apply {
completeExceptionally(permanentFailure)
},
)
)
val authenticator =
x509AttemptAuthenticatorForTest(nanoTime = now::get) { exchanges.removeFirst() }

try {
if (async) {
authenticator
.authenticateAsync(request(), Duration.ofSeconds(5))
.get(5, TimeUnit.SECONDS)
} else {
authenticator.authenticate(request(), Duration.ofSeconds(5))
}
now.set(Duration.ofMillis(425).toNanos())

if (async) {
assertThatThrownBy {
authenticator
.authenticateAsync(request(), Duration.ofSeconds(5))
.get(5, TimeUnit.SECONDS)
}
.isInstanceOf(ExecutionException::class.java)
.hasCause(permanentFailure)
} else {
assertThatThrownBy {
authenticator.authenticate(request(), Duration.ofSeconds(5))
}
.isSameAs(permanentFailure)
}
assertThat(exchanges).isEmpty()
} finally {
authenticator.close()
}
}
}
}

@Test
fun delayedExchangeCannotExtendTokenLifetime() {
val now = AtomicLong()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -78,12 +78,13 @@ internal class X509TestPeer(val authority: String, trustedClientRoot: X509Certif
init(arrayOf(serverIdentity.keyManager), arrayOf(recordingTrustManager), SecureRandom())
}

val server =
var server =
MockWebServer().apply {
useHttps(sslContext.socketFactory, true)
requireClientAuth()
start()
}
private set

val proxy: Proxy
get() = server.toProxyAddress()
Expand All @@ -104,6 +105,38 @@ internal class X509TestPeer(val authority: String, trustedClientRoot: X509Certif
"No request received by $authority within $timeout"
}

fun replaceWithUntrustedCertificate() {
val port = server.port
server.close()
val untrustedRoot =
HeldCertificate.Builder()
.commonName("$authority untrusted root")
.certificateAuthority(1)
.build()
val untrustedLeaf =
HeldCertificate.Builder()
.commonName(authority)
.addSubjectAlternativeName(authority)
.signedBy(untrustedRoot)
.build()
val untrustedIdentity =
HandshakeCertificates.Builder().heldCertificate(untrustedLeaf).build()
val untrustedContext =
SSLContext.getInstance("TLS").apply {
init(
arrayOf(untrustedIdentity.keyManager),
arrayOf(recordingTrustManager),
SecureRandom(),
)
}
server =
MockWebServer().apply {
useHttps(untrustedContext.socketFactory, true)
requireClientAuth()
start(port)
}
}

override fun close() {
server.close()
}
Expand Down
Loading
Loading