Repository navigation
feat(oauth2): implement IAM impersonation mTLS transport pinning and 401 recovery #14212
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. Weβll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 2 commits
dadeda6
5cc87e0
73c5a0a
8034525
17aae1f
f2d752e
a5e69ed
13e8be0
44ca6d7
9313161
6e5f7fe
ddd5ea8
b47ab2a
1f062f2
4e06ff0
d5317c2
091a91b
4bf0fdd
fe93d90
a9acfb5
16716d0
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -95,7 +95,7 @@ | |
|
|
||
| protected transient HttpTransportFactory transportFactory; | ||
|
|
||
| protected @Nullable ImpersonatedCredentials impersonatedCredentials; | ||
| protected volatile @Nullable ImpersonatedCredentials impersonatedCredentials; | ||
|
|
||
| private final EnvironmentProvider environmentProvider; | ||
| private final PropertyProvider propertyProvider; | ||
|
|
@@ -292,16 +292,19 @@ | |
| sourceCredentials = | ||
| AwsCredentials.newBuilder((AwsCredentials) this) | ||
| .setServiceAccountImpersonationUrl(null) | ||
| .setScopes(Collections.singletonList(OAuth2Utils.CLOUD_PLATFORM_SCOPE)) | ||
| .build(); | ||
| } else if (this instanceof PluggableAuthCredentials) { | ||
| sourceCredentials = | ||
| PluggableAuthCredentials.newBuilder((PluggableAuthCredentials) this) | ||
| .setServiceAccountImpersonationUrl(null) | ||
| .setScopes(Collections.singletonList(OAuth2Utils.CLOUD_PLATFORM_SCOPE)) | ||
|
lsirac marked this conversation as resolved.
Outdated
|
||
| .build(); | ||
| } else { | ||
| sourceCredentials = | ||
| IdentityPoolCredentials.newBuilder((IdentityPoolCredentials) this) | ||
| .setServiceAccountImpersonationUrl(null) | ||
| .setScopes(Collections.singletonList(OAuth2Utils.CLOUD_PLATFORM_SCOPE)) | ||
| .build(); | ||
| } | ||
|
Comment on lines
+295
to
302
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. can this just be simplified to sourceBuilder = toBuilder()?
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
|
|
||
|
|
@@ -363,7 +366,7 @@ | |
| * external source for authentication to Google Cloud Platform, you must validate it before | ||
| * providing it to any Google API or library. Providing an unvalidated credential configuration to | ||
| * Google APIs can compromise the security of your systems and data. For more information, refer | ||
| * to {@see <a | ||
|
Check failure on line 369 in google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/ExternalAccountCredentials.java
|
||
| * href="https://cloud.google.com/docs/authentication/external/externally-sourced-credentials">documentation</a>}. | ||
| * | ||
| * @param credentialsStream the stream with the credential definition | ||
|
|
@@ -384,7 +387,7 @@ | |
| * external source for authentication to Google Cloud Platform, you must validate it before | ||
| * providing it to any Google API or library. Providing an unvalidated credential configuration to | ||
| * Google APIs can compromise the security of your systems and data. For more information, refer | ||
| * to {@see <a | ||
|
Check failure on line 390 in google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/ExternalAccountCredentials.java
|
||
| * href="https://cloud.google.com/docs/authentication/external/externally-sourced-credentials">documentation</a>}. | ||
| * | ||
| * @param credentialsStream the stream with the credential definition | ||
|
|
@@ -526,6 +529,19 @@ | |
| return this.serviceAccountImpersonationUrl != null && this.impersonatedCredentials == null; | ||
| } | ||
|
|
||
| /** | ||
| * Refreshes the access token using the specified transport factory. Default implementation | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Nit: The Javadoc says the default implementation delegates to
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Updated the Javadoc on ExternalAccountCredentials.refreshAccessToken(HttpTransportFactory) to clarify that internal subclasses delegate refreshAccessToken() into this method while the default implementation delegates back to refreshAccessToken(), and renamed the parameter to cycleTransportFactory across all classes to avoid shadowing this.transportFactory. |
||
| * delegates to {@link #refreshAccessToken()}. Subclasses should override this method if they | ||
| * support transport pinning per refresh cycle. | ||
| * | ||
| * @param transportFactory the HTTP transport factory to use for this refresh cycle | ||
| * @return the refreshed access token | ||
| * @throws IOException if the token refresh fails | ||
| */ | ||
| public AccessToken refreshAccessToken(HttpTransportFactory transportFactory) throws IOException { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The default
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Annotated public AccessToken refreshAccessToken(HttpTransportFactory cycleTransportFactory) with @InternalExtensionOnly across ExternalAccountCredentials, IdentityPoolCredentials, AwsCredentials, and PluggableAuthCredentials (and kept ImpersonatedCredentials.refreshAccessToken(HttpTransportFactory) package-private). |
||
| return refreshAccessToken(); | ||
| } | ||
|
|
||
| /** | ||
| * Exchanges the external credential for a Google Cloud access token. | ||
| * | ||
|
|
@@ -556,7 +572,7 @@ | |
| this.impersonatedCredentials = this.buildImpersonatedCredentials(); | ||
| } | ||
| if (this.impersonatedCredentials != null) { | ||
| return this.impersonatedCredentials.refreshAccessToken(); | ||
| return this.impersonatedCredentials.refreshAccessToken(cycleTransportFactory); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. When
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Done! Added ImpersonatedCredentials impersonated = getImpersonatedCredentials(); at the start of: IdentityPoolCredentials.refreshWithRetry, AwsCredentials.refreshAccessToken, and Subject and actor tokens (and AWS metadata requests) are now fetched only once per refresh attempt (or twice on a 401 retry). Kept the check inside exchangeExternalCredentialForAccessToken as a defensive fallback if called directly. |
||
| } | ||
|
|
||
| StsRequestHandler.Builder requestHandler = | ||
|
|
@@ -626,6 +642,7 @@ | |
| // Properly deserialize the transient transportFactory. | ||
| input.defaultReadObject(); | ||
| transportFactory = newInstance(transportFactoryClassName); | ||
| impersonatedCredentials = null; | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Nit:
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Removed the redundant this.impersonatedCredentials = null; assignment in readObject(). |
||
| } | ||
|
|
||
| public @Nullable String getServiceAccountImpersonationUrl() { | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -72,7 +72,7 @@ public class IdentityPoolCredentials extends ExternalAccountCredentials { | |
| private final @Nullable String actorTokenType; | ||
| // Transient: not serialized directly. Reconstructed in readObject() from the credentialSource | ||
| // certificate config so deserialized credentials remain usable for mTLS and refresh. | ||
| private transient @Nullable X509Provider x509Provider; | ||
| private transient volatile @Nullable X509Provider x509Provider; | ||
| private final ExternalAccountSupplierContext supplierContext; | ||
| private final String metricsHeaderValue; | ||
|
|
||
|
|
@@ -115,7 +115,7 @@ public class IdentityPoolCredentials extends ExternalAccountCredentials { | |
| || builder.transportFactory == OAuth2Utils.HTTP_TRANSPORT_FACTORY | ||
| || builder.transportFactory instanceof OAuth2Utils.DefaultHttpTransportFactory | ||
| || builder.transportFactory.getClass() == MtlsHttpTransportFactory.class) { | ||
| this.transportFactory = new MtlsHttpTransportFactory(mtlsKeyStore); | ||
| this.transportFactory = createMtlsTransportFactory(mtlsKeyStore); | ||
| } else if (!(builder.transportFactory instanceof MtlsHttpTransportFactory)) { | ||
| LOGGER_PROVIDER | ||
| .getLogger() | ||
|
|
@@ -182,7 +182,7 @@ public class IdentityPoolCredentials extends ExternalAccountCredentials { | |
| if (this.actorTokenSupplier != null && !isMtlsConfigured()) { | ||
| throw new IllegalArgumentException( | ||
| "Actor tokens are only supported for mTLS token exchanges. Please configure a certificate" | ||
| + " source or MtlsHttpTransportFactory."); | ||
| + " configuration in the credential source or provide an mTLS-enabled transport."); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Nit: The exception message says "or provide an mTLS-enabled transport", but
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Updated the exception message (and matching test assertion) to "or provide an MtlsHttpTransportFactory constructed with a KeyStore.". |
||
| } | ||
|
|
||
| if (this.actorTokenSupplier != null) { | ||
|
|
@@ -228,15 +228,35 @@ private boolean isMtlsConfigured() { | |
| && ((MtlsHttpTransportFactory) this.transportFactory).hasKeyStore()); | ||
| } | ||
|
|
||
| private boolean shouldUseMtlsTransportFactory() { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. In
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Added (this.transportFactory instanceof MtlsHttpTransportFactory && !((MtlsHttpTransportFactory) this.transportFactory).hasKeyStore()) directly into shouldUseMtlsTransportFactory() and simplified readObject() to call if (shouldUseMtlsTransportFactory()). |
||
| return this.transportFactory == null | ||
| || this.transportFactory == OAuth2Utils.HTTP_TRANSPORT_FACTORY | ||
| || this.transportFactory instanceof OAuth2Utils.DefaultHttpTransportFactory | ||
| || this.transportFactory.getClass() == MtlsHttpTransportFactory.class; | ||
| } | ||
|
|
||
| @Override | ||
| public AccessToken refreshAccessToken() throws IOException { | ||
| // Per-cycle cert pinning: snapshot the KeyStore at the start of each refresh cycle. | ||
| HttpTransportFactory cycleTransportFactory = this.transportFactory; | ||
| if (this.x509Provider != null && this.transportFactory instanceof MtlsHttpTransportFactory) { | ||
| if (this.x509Provider != null && shouldUseMtlsTransportFactory()) { | ||
| KeyStore pinnedKeyStore = this.x509Provider.getKeyStore(); | ||
| cycleTransportFactory = new MtlsHttpTransportFactory(pinnedKeyStore); | ||
| cycleTransportFactory = createMtlsTransportFactory(pinnedKeyStore); | ||
| } | ||
| return refreshWithRetry(cycleTransportFactory, true); | ||
| } | ||
|
|
||
| @Override | ||
| public AccessToken refreshAccessToken(HttpTransportFactory cycleTransportFactory) | ||
| throws IOException { | ||
| // Retry is intentionally disabled when an explicit cycleTransportFactory is supplied to | ||
| // ensure transport synchronization across multi-step token exchanges (e.g. STS and IAM) | ||
| // and prevent nested retry amplification. Outer callers manage retry coordination. | ||
| return refreshWithRetry(cycleTransportFactory, false); | ||
| } | ||
|
|
||
| private AccessToken refreshWithRetry( | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. a few quick notes: This method is quite large and it's a bit hard to understand what is going on here (also admittedly I'm not as familiar with IdentityPoolCredentials or 3p creds in general). Can we split this into a series of helpers. On a high level I think I see a few things
I think we can keep the logic regarding retries and all that inside this method
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Done β extracted
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Thanks. I still find this a bit hard to follow. I think the cor elogic is in helpers, but the main challenge is with the retry logic. Is it possible to not use recursion for this? IMO it's easier to follow just seeing the 1 or 2 requests flowing in the code. I think it may be easier to separate the refresh call from the retry logic. Perhaps we can create a helper for making the refresh call and invoke it once or twice inside this method depending on whether we are allowed to retry or not.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Done β separated the single-attempt refresh execution into |
||
| HttpTransportFactory cycleTransportFactory, boolean allowRetry) throws IOException { | ||
| // Read subject and actor tokens, atomically if from the same file supplier. | ||
| String subjectToken; | ||
| String actorToken = null; | ||
|
|
@@ -270,22 +290,52 @@ public AccessToken refreshAccessToken() throws IOException { | |
| try { | ||
| return exchangeExternalCredentialForAccessToken( | ||
| stsTokenExchangeRequest.build(), cycleTransportFactory); | ||
| } catch (OAuthException e) { | ||
| if (e.getHttpStatusCode() == 401 | ||
| } catch (Exception e) { | ||
| if (allowRetry | ||
| && OAuth2Utils.isUnauthorizedException(e) | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Added OAuth2Utils.hasCertificateChanged(pinnedKeyStore, freshKeyStore) and checked it before retrying on a 401; if the reloaded certificate chain has not changed, the original exception is rethrown immediately without retrying STS/IAM. |
||
| && this.x509Provider != null | ||
| && this.transportFactory instanceof MtlsHttpTransportFactory) { | ||
| && shouldUseMtlsTransportFactory()) { | ||
| KeyStore freshKeyStore; | ||
| try { | ||
| // On 401, re-read from X509Provider for fresh certs and retry once. | ||
| KeyStore freshKeyStore = this.x509Provider.getKeyStore(); | ||
| HttpTransportFactory retryTransportFactory = new MtlsHttpTransportFactory(freshKeyStore); | ||
| return exchangeExternalCredentialForAccessToken( | ||
| stsTokenExchangeRequest.build(), retryTransportFactory); | ||
| } catch (IOException retryException) { | ||
| retryException.addSuppressed(e); | ||
| throw retryException; | ||
| // On 401, re-read from X509Provider for fresh certs. | ||
| freshKeyStore = this.x509Provider.getKeyStore(); | ||
| } catch (IOException reloadException) { | ||
| if (reloadException != e) { | ||
| reloadException.addSuppressed(e); | ||
| } | ||
| throw reloadException; | ||
| } catch (Exception reloadException) { | ||
| IOException ioException = | ||
| new IOException("Failed to reload certificate on retry", reloadException); | ||
| if (ioException != e) { | ||
| ioException.addSuppressed(e); | ||
| } | ||
| throw ioException; | ||
| } | ||
|
|
||
| HttpTransportFactory retryTransportFactory = createMtlsTransportFactory(freshKeyStore); | ||
| try { | ||
| return refreshWithRetry(retryTransportFactory, false); | ||
| } catch (Exception retryException) { | ||
| if (retryException != e) { | ||
| retryException.addSuppressed(e); | ||
| } | ||
| if (retryException instanceof IOException) { | ||
| throw (IOException) retryException; | ||
| } | ||
| if (retryException instanceof RuntimeException) { | ||
| throw (RuntimeException) retryException; | ||
| } | ||
| throw new IOException(retryException); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Nit:
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Simplified both catch blocks to catch IOException | RuntimeException and rethrow directly (throw retryException / throw e). |
||
| } | ||
| } | ||
| if (e instanceof IOException) { | ||
| throw (IOException) e; | ||
| } | ||
| if (e instanceof RuntimeException) { | ||
| throw (RuntimeException) e; | ||
| } | ||
| throw e; | ||
| throw new IOException(e); | ||
| } | ||
| } | ||
|
|
||
|
|
@@ -324,6 +374,11 @@ HttpTransportFactory getTransportFactory() { | |
| return this.x509Provider; | ||
| } | ||
|
|
||
| @VisibleForTesting | ||
| HttpTransportFactory createMtlsTransportFactory(KeyStore keyStore) { | ||
| return new MtlsHttpTransportFactory(keyStore); | ||
| } | ||
|
|
||
| /** Clones the IdentityPoolCredentials with the specified scopes. */ | ||
| @Override | ||
| public IdentityPoolCredentials createScoped(Collection<String> newScopes) { | ||
|
|
@@ -353,7 +408,7 @@ private IdentityPoolSubjectTokenSupplier createCertificateSubjectTokenSupplier( | |
| || builder.transportFactory == OAuth2Utils.HTTP_TRANSPORT_FACTORY | ||
| || builder.transportFactory instanceof OAuth2Utils.DefaultHttpTransportFactory | ||
| || builder.transportFactory.getClass() == MtlsHttpTransportFactory.class) { | ||
| this.transportFactory = new MtlsHttpTransportFactory(mtlsKeyStore); | ||
| this.transportFactory = createMtlsTransportFactory(mtlsKeyStore); | ||
| } else if (!(builder.transportFactory instanceof MtlsHttpTransportFactory)) { | ||
| LOGGER_PROVIDER | ||
| .getLogger() | ||
|
|
@@ -395,7 +450,9 @@ private void readObject(ObjectInputStream input) throws IOException, ClassNotFou | |
| new X509Provider(getEnvironmentProvider(), getPropertyProvider(), explicitCertConfigPath); | ||
| try { | ||
| KeyStore mtlsKeyStore = this.x509Provider.getKeyStore(); | ||
| this.transportFactory = new MtlsHttpTransportFactory(mtlsKeyStore); | ||
| if (shouldUseMtlsTransportFactory()) { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. After deserialization this only rebuilds the factory when
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Done - updated readObject to also rebuild this.transportFactory via createMtlsTransportFactory(this.x509Provider.getKeyStore()) when this.transportFactory instanceof MtlsHttpTransportFactory && !((MtlsHttpTransportFactory) this.transportFactory).hasKeyStore().
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. For
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Persisted |
||
| this.transportFactory = createMtlsTransportFactory(mtlsKeyStore); | ||
| } | ||
| } catch (Exception e) { | ||
| // Cert loading failure will be handled on refreshAccessToken() | ||
| } | ||
|
|
@@ -441,8 +498,11 @@ public static class Builder extends ExternalAccountCredentials.Builder { | |
| if (this.credentialSource == null) { | ||
| this.subjectTokenSupplier = credentials.subjectTokenSupplier; | ||
| this.actorTokenSupplier = credentials.actorTokenSupplier; | ||
| } else if (credentials.actorTokenSupplier != credentials.subjectTokenSupplier) { | ||
| this.actorTokenSupplier = credentials.actorTokenSupplier; | ||
| } | ||
| // Note: when credentialSource is present, subjectTokenSupplier and actorTokenSupplier | ||
| // Note: when credentialSource is present, subjectTokenSupplier and file-based | ||
| // actorTokenSupplier | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Nit:
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Re-wrapped the comment block in Builder(IdentityPoolCredentials). |
||
| // are intentionally NOT copied here. They will be reconstructed from credentialSource | ||
| // during build(), which ensures they share the same FileIdentityPoolSubjectTokenSupplier | ||
| // instance for atomic token reads. | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Nit: Since
readObjectresetsimpersonatedCredentialstonullat line 655 andserialVersionUIDis explicit, can we markimpersonatedCredentialstransientso Java does not serialize the inner credential graph just to discard it?There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Marked impersonatedCredentials transient and added a serialization round-trip test in ExternalAccountCredentialsTest.