1+ /*
2+ * Copyright 2025 Google LLC
3+ *
4+ * Redistribution and use in source and binary forms, with or without
5+ * modification, are permitted provided that the following conditions are
6+ * met:
7+ *
8+ * * Redistributions of source code must retain the above copyright
9+ * notice, this list of conditions and the following disclaimer.
10+ * * Redistributions in binary form must reproduce the above
11+ * copyright notice, this list of conditions and the following disclaimer
12+ * in the documentation and/or other materials provided with the
13+ * distribution.
14+ *
15+ * * Neither the name of Google LLC nor the names of its
16+ * contributors may be used to endorse or promote products derived from
17+ * this software without specific prior written permission.
18+ *
19+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
20+ * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
21+ * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
22+ * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
23+ * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
24+ * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
25+ * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
26+ * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
27+ * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
28+ * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
29+ * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
30+ */
31+
32+ package com .google .auth .oauth2 ;
33+
34+ import com .google .api .client .json .GenericJson ;
35+ import com .google .api .client .json .JsonObjectParser ;
36+ import com .google .common .annotations .VisibleForTesting ;
37+ import com .google .common .base .Strings ;
38+ import com .google .common .collect .ImmutableList ;
39+ import com .google .common .io .BaseEncoding ;
40+ import java .io .FileInputStream ;
41+ import java .io .IOException ;
42+ import java .io .InputStream ;
43+ import java .nio .charset .StandardCharsets ;
44+ import java .nio .file .Files ;
45+ import java .nio .file .Paths ;
46+ import java .security .GeneralSecurityException ;
47+ import java .security .MessageDigest ;
48+ import java .security .cert .CertificateFactory ;
49+ import java .security .cert .CertificateParsingException ;
50+ import java .security .cert .X509Certificate ;
51+ import java .util .Collection ;
52+ import java .util .List ;
53+ import java .util .Map ;
54+ import java .util .logging .Level ;
55+ import java .util .logging .Logger ;
56+ import java .util .regex .Pattern ;
57+
58+ /** Internal utility class for handling Agent Identity certificate-bound access tokens. */
59+ final class AgentIdentityUtils {
60+ private static final Logger LOGGER = Logger .getLogger (AgentIdentityUtils .class .getName ());
61+
62+ static final String GOOGLE_API_CERTIFICATE_CONFIG = "GOOGLE_API_CERTIFICATE_CONFIG" ;
63+ static final String GOOGLE_API_PREVENT_AGENT_TOKEN_SHARING_FOR_GCP_SERVICES =
64+ "GOOGLE_API_PREVENT_AGENT_TOKEN_SHARING_FOR_GCP_SERVICES" ;
65+
66+ private static final List <Pattern > AGENT_IDENTITY_SPIFFE_PATTERNS =
67+ ImmutableList .of (
68+ Pattern .compile ("^agents\\ .global\\ .org-\\ d+\\ .system\\ .id\\ .goog$" ),
69+ Pattern .compile ("^agents\\ .global\\ .proj-\\ d+\\ .system\\ .id\\ .goog$" ));
70+
71+ // Polling configuration
72+ static long TOTAL_TIMEOUT_MS = 30000 ; // 30 seconds
73+ static long FAST_POLL_DURATION_MS = 5000 ; // 5 seconds
74+ static long FAST_POLL_INTERVAL_MS = 100 ; // 0.1 seconds
75+ static long SLOW_POLL_INTERVAL_MS = 500 ; // 0.5 seconds
76+
77+ private static final int SAN_URI_TYPE = 6 ;
78+ private static final String SPIFFE_SCHEME_PREFIX = "spiffe://" ;
79+
80+ // Interface to allow mocking System.getenv for tests without exposing it publicly.
81+ interface EnvReader {
82+ String getEnv (String name );
83+ }
84+
85+ private static EnvReader envReader = System ::getenv ;
86+
87+ private AgentIdentityUtils () {}
88+
89+ /**
90+ * Gets the Agent Identity certificate if available and enabled.
91+ *
92+ * @return The X509Certificate if found and Agent Identities are enabled, null otherwise.
93+ * @throws IOException If there is an error reading the certificate file after retries.
94+ */
95+ static X509Certificate getAgentIdentityCertificate () throws IOException {
96+ if (isOptedOut ()) {
97+ return null ;
98+ }
99+
100+ String certConfigPath = envReader .getEnv (GOOGLE_API_CERTIFICATE_CONFIG );
101+ if (Strings .isNullOrEmpty (certConfigPath )) {
102+ return null ;
103+ }
104+
105+ String certPath = getCertificatePathWithRetry (certConfigPath );
106+ return parseCertificate (certPath );
107+ }
108+
109+ /** Checks if the user has opted out of Agent Token sharing. */
110+ private static boolean isOptedOut () {
111+ String optOut = envReader .getEnv (GOOGLE_API_PREVENT_AGENT_TOKEN_SHARING_FOR_GCP_SERVICES );
112+ return optOut != null && "false" .equalsIgnoreCase (optOut );
113+ }
114+
115+ /** Polls for the certificate config file and the certificate file it references. */
116+ private static String getCertificatePathWithRetry (String certConfigPath ) throws IOException {
117+ long startTime = System .currentTimeMillis ();
118+ boolean warned = false ;
119+
120+ while (true ) {
121+ try {
122+ if (Files .exists (Paths .get (certConfigPath ))) {
123+ String certPath = extractCertPathFromConfig (certConfigPath );
124+ if (!Strings .isNullOrEmpty (certPath ) && Files .exists (Paths .get (certPath ))) {
125+ return certPath ;
126+ }
127+ }
128+ } catch (Exception e ) {
129+ // Ignore exceptions during polling and retry
130+ LOGGER .log (Level .FINE , "Error while polling for certificate files" , e );
131+ }
132+
133+ long elapsedTime = System .currentTimeMillis () - startTime ;
134+ if (elapsedTime >= TOTAL_TIMEOUT_MS ) {
135+ throw new IOException (
136+ "Certificate config or certificate file not found after multiple retries. "
137+ + "Token binding protection is failing. You can turn off this protection by setting "
138+ + GOOGLE_API_PREVENT_AGENT_TOKEN_SHARING_FOR_GCP_SERVICES
139+ + " to false to fall back to unbound tokens." );
140+ }
141+
142+ if (!warned ) {
143+ LOGGER .warning (
144+ String .format (
145+ "Certificate config file not found at %s (from %s environment variable). "
146+ + "Retrying for up to %d seconds." ,
147+ certConfigPath ,
148+ GOOGLE_API_CERTIFICATE_CONFIG ,
149+ TOTAL_TIMEOUT_MS / 1000 ));
150+ warned = true ;
151+ }
152+
153+ try {
154+ long sleepTime =
155+ elapsedTime < FAST_POLL_DURATION_MS ? FAST_POLL_INTERVAL_MS : SLOW_POLL_INTERVAL_MS ;
156+ Thread .sleep (sleepTime );
157+ } catch (InterruptedException e ) {
158+ Thread .currentThread ().interrupt ();
159+ throw new IOException ("Interrupted while waiting for certificate files" , e );
160+ }
161+ }
162+ }
163+
164+ @ SuppressWarnings ("unchecked" )
165+ private static String extractCertPathFromConfig (String certConfigPath ) throws IOException {
166+ try (InputStream stream = new FileInputStream (certConfigPath )) {
167+ JsonObjectParser parser = new JsonObjectParser (OAuth2Utils .JSON_FACTORY );
168+ GenericJson config =
169+ parser .parseAndClose (stream , StandardCharsets .UTF_8 , GenericJson .class );
170+ Map <String , Object > certConfigs = (Map <String , Object >) config .get ("cert_configs" );
171+ if (certConfigs != null ) {
172+ Map <String , Object > workload = (Map <String , Object >) certConfigs .get ("workload" );
173+ if (workload != null ) {
174+ return (String ) workload .get ("cert_path" );
175+ }
176+ }
177+ }
178+ return null ;
179+ }
180+
181+ private static X509Certificate parseCertificate (String certPath ) throws IOException {
182+ try (InputStream stream = new FileInputStream (certPath )) {
183+ CertificateFactory cf = CertificateFactory .getInstance ("X.509" );
184+ return (X509Certificate ) cf .generateCertificate (stream );
185+ } catch (GeneralSecurityException e ) {
186+ throw new IOException ("Failed to parse certificate" , e );
187+ }
188+ }
189+
190+ /** Checks if the certificate belongs to an Agent Identity by inspecting SANs. */
191+ static boolean shouldRequestBoundToken (X509Certificate cert ) {
192+ try {
193+ Collection <List <?>> sans = cert .getSubjectAlternativeNames ();
194+ if (sans == null ) {
195+ return false ;
196+ }
197+ for (List <?> san : sans ) {
198+ // SAN entry is a list where first element is the type (Integer) and second is value (mostly
199+ // String)
200+ if (san .size () >= 2
201+ && san .get (0 ) instanceof Integer
202+ && (Integer ) san .get (0 ) == SAN_URI_TYPE ) {
203+ Object value = san .get (1 );
204+ if (value instanceof String ) {
205+ String uri = (String ) value ;
206+ if (uri .startsWith (SPIFFE_SCHEME_PREFIX )) {
207+ // Extract trust domain: spiffe://<trust_domain>/...
208+ String withoutScheme = uri .substring (SPIFFE_SCHEME_PREFIX .length ());
209+ int slashIndex = withoutScheme .indexOf ('/' );
210+ String trustDomain =
211+ (slashIndex == -1 ) ? withoutScheme : withoutScheme .substring (0 , slashIndex );
212+
213+ for (Pattern pattern : AGENT_IDENTITY_SPIFFE_PATTERNS ) {
214+ if (pattern .matcher (trustDomain ).matches ()) {
215+ return true ;
216+ }
217+ }
218+ }
219+ }
220+ }
221+ }
222+ } catch (CertificateParsingException e ) {
223+ LOGGER .log (Level .WARNING , "Failed to parse Subject Alternative Names from certificate" , e );
224+ }
225+ return false ;
226+ }
227+
228+ /** Calculates the SHA-256 fingerprint of the certificate, Base64Url encoded without padding. */
229+ static String calculateCertificateFingerprint (X509Certificate cert ) throws IOException {
230+ try {
231+ MessageDigest md = MessageDigest .getInstance ("SHA-256" );
232+ byte [] der = cert .getEncoded ();
233+ md .update (der );
234+ byte [] digest = md .digest ();
235+ return BaseEncoding .base64Url ().omitPadding ().encode (digest );
236+ } catch (GeneralSecurityException e ) {
237+ throw new IOException ("Failed to calculate certificate fingerprint" , e );
238+ }
239+ }
240+
241+ @ VisibleForTesting
242+ static void setEnvReader (EnvReader reader ) {
243+ envReader = reader ;
244+ }
245+ }
0 commit comments