From 866ff5b2e2e6c95386adf5360e1b14654c2b78a4 Mon Sep 17 00:00:00 2001 From: Francisco Javier Alarcon Esparza Date: Sat, 31 May 2025 03:18:15 +0000 Subject: [PATCH 01/10] feat(cloudrun): add 'cloudrun_service_to_service_receive' sample --- run/service-auth/pom.xml | 110 +++++++++ .../example/serviceauth/Authentication.java | 135 +++++++++++ .../serviceauth/AuthenticationTests.java | 209 ++++++++++++++++++ 3 files changed, 454 insertions(+) create mode 100644 run/service-auth/pom.xml create mode 100644 run/service-auth/src/main/java/com/example/serviceauth/Authentication.java create mode 100644 run/service-auth/src/test/java/com/example/serviceauth/AuthenticationTests.java diff --git a/run/service-auth/pom.xml b/run/service-auth/pom.xml new file mode 100644 index 00000000000..103c2685413 --- /dev/null +++ b/run/service-auth/pom.xml @@ -0,0 +1,110 @@ + + + + 4.0.0 + com.example.run + service-auth + 0.0.1-SNAPSHOT + jar + + + + + com.google.cloud.samples + shared-configuration + 1.2.0 + + + + + + org.springframework.boot + spring-boot-dependencies + ${spring-boot.version} + pom + import + + + + + UTF-8 + UTF-8 + 17 + 17 + 3.2.2 + + + + org.springframework.boot + spring-boot-starter-web + + + org.springframework.boot + spring-boot-starter-test + test + + + org.junit.vintage + junit-vintage-engine + test + + + junit + junit + test + + + com.google.api-client + google-api-client + 2.7.2 + + + com.google.http-client + google-http-client + 1.47.0 + + + com.google.auth + google-auth-library-oauth2-http + 1.35.0 + + + + + + org.springframework.boot + spring-boot-maven-plugin + ${spring-boot.version} + + + + repackage + + + + + + com.google.cloud.tools + jib-maven-plugin + 3.4.0 + + + gcr.io/PROJECT_ID/service-auth + + + + + + \ No newline at end of file diff --git a/run/service-auth/src/main/java/com/example/serviceauth/Authentication.java b/run/service-auth/src/main/java/com/example/serviceauth/Authentication.java new file mode 100644 index 00000000000..9c288484e31 --- /dev/null +++ b/run/service-auth/src/main/java/com/example/serviceauth/Authentication.java @@ -0,0 +1,135 @@ +/* + * Copyright 2025 Google LLC + * + * 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 com.example.serviceauth; + +// [START cloudrun_service_to_service_receive] + +import com.google.api.client.googleapis.auth.oauth2.GoogleIdToken; +import com.google.api.client.googleapis.auth.oauth2.GoogleIdTokenVerifier; +import com.google.api.client.http.apache.v2.ApacheHttpTransport; +import com.google.api.client.json.gson.GsonFactory; +import java.util.Arrays; +import java.util.Collection; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Service; +import org.springframework.web.bind.annotation.CrossOrigin; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RestController; + +@SpringBootApplication +public class Authentication { + @RestController + @CrossOrigin(exposedHeaders = "*", allowedHeaders = "*") + class AuthenticationController { + + @Autowired private AuthenticationService authService; + + @GetMapping("/") + public ResponseEntity getEmailFromAuthHeader( + @RequestHeader("X-Serverless-Authorization") String authHeader) { + String responseBody; + if (authHeader == null) { + responseBody = "Error verifying ID token: missing X-Serverless-Authorization header"; + return new ResponseEntity<>(responseBody, HttpStatus.UNAUTHORIZED); + } + + String email = authService.parseAuthHeader(authHeader); + if (email == null) { + responseBody = "Unauthorized request. Please supply a valid bearer token."; + HttpHeaders headers = new HttpHeaders(); + headers.add("WWW-Authenticate", "Bearer"); + return new ResponseEntity<>(responseBody, headers, HttpStatus.UNAUTHORIZED); + } + + responseBody = "Hello, " + email; + return new ResponseEntity<>(responseBody, HttpStatus.OK); + } + } + + @Service + public class AuthenticationService { + /* + * Parse the authorization header, validate and decode the Bearer token. + * + * Args: + * authHeader: String of HTTP header with a Bearer token. + * + * Returns: + * A string containing the email from the token. + * null if the token is invalid or the email can't be retrieved. + */ + public String parseAuthHeader(String authHeader) { + // Split the auth type and value from the header. + String[] authHeaderStrings = authHeader.split(" "); + if (authHeaderStrings.length != 2) { + System.out.println("Malformed Authorization header"); + return null; + } + String authType = authHeaderStrings[0]; + String tokenValue = authHeaderStrings[1]; + + // Get the service URL from the environment variable + // set at the time of deployment. + String serviceUrl = System.getenv("SERVICE_URL"); + // Define the expected audience as the Service Base URL. + Collection audience = Arrays.asList(serviceUrl); + + // Validate and decode the ID token in the header. + if ("Bearer".equals(authType)) { + try { + // Find more information about the verification process in: + // https://developers.google.com/identity/sign-in/web/backend-auth#java + // https://cloud.google.com/java/docs/reference/google-api-client/latest/com.google.api.client.googleapis.auth.oauth2.GoogleIdTokenVerifier + GoogleIdTokenVerifier verifier = + new GoogleIdTokenVerifier.Builder(new ApacheHttpTransport(), new GsonFactory()) + .setAudience(audience) + .build(); + GoogleIdToken googleIdToken = verifier.verify(tokenValue); + + if (googleIdToken != null) { + // More info about the structure for the decoded ID Token here: + // https://cloud.google.com/docs/authentication/token-types#id + // https://cloud.google.com/java/docs/reference/google-api-client/latest/com.google.api.client.googleapis.auth.oauth2.GoogleIdToken + // https://cloud.google.com/java/docs/reference/google-api-client/latest/com.google.api.client.googleapis.auth.oauth2.GoogleIdToken.Payload + GoogleIdToken.Payload payload = googleIdToken.getPayload(); + if (payload.getEmailVerified()) { + return payload.getEmail(); + } + System.out.println("Invalid token. Email wasn't verified."); + } + } catch (Exception exception) { + System.out.println("Ivalid token: " + exception); + } + } else { + System.out.println("Unhandled header format: " + authType); + } + return null; + } + } + + public static void main(String[] args) { + SpringApplication.run(Authentication.class, args); + } + + // [END cloudrun_service_to_service_receive] +} diff --git a/run/service-auth/src/test/java/com/example/serviceauth/AuthenticationTests.java b/run/service-auth/src/test/java/com/example/serviceauth/AuthenticationTests.java new file mode 100644 index 00000000000..212ad994aa5 --- /dev/null +++ b/run/service-auth/src/test/java/com/example/serviceauth/AuthenticationTests.java @@ -0,0 +1,209 @@ +/* + * Copyright 2025 Google LLC + * + * 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 com.example.serviceauth; + +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.google.api.client.http.HttpStatusCodes; +import com.google.auth.oauth2.GoogleCredentials; +import com.google.auth.oauth2.IdTokenCredentials; +import com.google.auth.oauth2.IdTokenProvider; +import com.google.auth.oauth2.IdTokenProvider.Option; +import java.io.IOException; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.net.http.HttpTimeoutException; +import java.nio.charset.StandardCharsets; +import java.util.Arrays; +import java.util.List; +import java.util.UUID; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; + +@SpringBootTest +public class AuthenticationTests { + + private static String PROJECT_ID = System.getenv("GOOGLE_CLOUD_PROJECT"); + private static String REGION = "us-central1"; + private String projectNumber; + private String serviceUrl; + private String serviceName; + private HttpClient httpClient; + + @BeforeEach + public void setUp() { + this.projectNumber = getProjectNumber(); + this.serviceName = generateServiceName(); + this.serviceUrl = generateServiceUrl(); + this.deployService(); + + this.httpClient = HttpClient.newHttpClient(); + } + + @AfterEach + public void tearDown() { + this.deleteService(); + } + + private String getProjectNumber() { + return getOutputFromCommand( + List.of("gcloud", "projects", "describe", PROJECT_ID, "--format=value(projectNumber)")); + } + + private String generateServiceName() { + return String.format("receive-java-%s", UUID.randomUUID().toString().substring(0, 8)); + } + + private String generateServiceUrl() { + return String.format("https://%s-%s.%s.run.app", this.serviceName, this.projectNumber, REGION); + } + + private String deployService() { + return getOutputFromCommand( + List.of( + "gcloud", + "run", + "deploy", + serviceName, + "--project", + PROJECT_ID, + "--source", + ".", + "--region=" + REGION, + "--allow-unauthenticated", + "--set-env-vars=SERVICE_URL=" + serviceUrl, + "--quiet")); + } + + private String deleteService() { + return getOutputFromCommand( + List.of( + "gcloud", + "run", + "services", + "delete", + serviceName, + "--project", + PROJECT_ID, + "--async", + "--region=" + REGION, + "--quiet")); + } + + private String getOutputFromCommand(List command) { + try { + ProcessBuilder processBuilder = new ProcessBuilder(command); + + Process process = processBuilder.start(); + String output = + new String(process.getInputStream().readAllBytes(), StandardCharsets.UTF_8).strip(); + + process.waitFor(); + + return output; + } catch (InterruptedException | IOException exception) { + return String.format("Exception: %s", exception); + } + } + + private String getGoogleIdToken() { + try { + GoogleCredentials googleCredentials = GoogleCredentials.getApplicationDefault(); + + IdTokenCredentials idTokenCredentials = + IdTokenCredentials.newBuilder() + .setIdTokenProvider((IdTokenProvider) googleCredentials) + .setTargetAudience(serviceUrl) + .setOptions(Arrays.asList(Option.FORMAT_FULL, Option.LICENSES_TRUE)) + .build(); + + return idTokenCredentials.refreshAccessToken().getTokenValue(); + } catch (IOException exception) { + return "error_generating_token"; + } + } + + private HttpResponse executeRequest(String headerName, String headerValue) { + HttpRequest.Builder requestBuilder = HttpRequest.newBuilder().uri(URI.create(serviceUrl)).GET(); + if (headerName != null) { + requestBuilder = requestBuilder.header(headerName, headerValue); + } + HttpRequest request = requestBuilder.build(); + HttpResponse response = null; + int retryDelay = 2000; + int retryLimit = 3; + + for (int attempt = 0; attempt < retryLimit; attempt++) { + try { + response = this.httpClient.send(request, HttpResponse.BodyHandlers.ofString()); + switch (response.statusCode()) { + case HttpStatusCodes.STATUS_CODE_OK: + case HttpStatusCodes.STATUS_CODE_UNAUTHORIZED: + return response; + } + } catch (HttpTimeoutException exception) { + System.out.println(String.format("TimeoutException: %s", exception)); + System.out.println("Retrying..."); + } catch (IOException | InterruptedException exception) { + System.out.println(String.format("Exception: %s", exception)); + System.out.println("Retrying..."); + } + + try { + Thread.sleep(retryDelay); + } catch (InterruptedException exception) { + Thread.currentThread().interrupt(); + return null; + } + } + + return null; + } + + @Test + public void testValidToken() throws Exception { + String token = getGoogleIdToken(); + HttpResponse response = executeRequest("X-Serverless-Authorization", "Bearer " + token); + + assertTrue(response != null); + assertTrue(response.statusCode() == HttpStatusCodes.STATUS_CODE_OK); + assertTrue(response.body().contains("Hello,")); + } + + @Test + public void testInvalidToken() throws Exception { + String token = "invalid_token"; + HttpResponse response = executeRequest("X-Serverless-Authorization", "Bearer " + token); + + assertTrue(response != null); + assertTrue(response.statusCode() == HttpStatusCodes.STATUS_CODE_UNAUTHORIZED); + assertTrue(response.body().contains("Please supply a valid bearer token.")); + } + + @Test + public void testAnonymousRequest() throws Exception { + HttpResponse response = executeRequest(null, null); + + assertTrue(response != null); + assertTrue(response.statusCode() == HttpStatusCodes.STATUS_CODE_UNAUTHORIZED); + assertTrue(response.body().contains("missing X-Serverless-Authorization header")); + } +} From 747db93d9148379d99d0928920f718f335b91df5 Mon Sep 17 00:00:00 2001 From: Francisco Javier Alarcon Esparza Date: Sat, 31 May 2025 05:40:19 +0000 Subject: [PATCH 02/10] fix lint issue --- .../java/com/example/serviceauth/AuthenticationTests.java | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/run/service-auth/src/test/java/com/example/serviceauth/AuthenticationTests.java b/run/service-auth/src/test/java/com/example/serviceauth/AuthenticationTests.java index 212ad994aa5..36494aa3ee4 100644 --- a/run/service-auth/src/test/java/com/example/serviceauth/AuthenticationTests.java +++ b/run/service-auth/src/test/java/com/example/serviceauth/AuthenticationTests.java @@ -154,10 +154,9 @@ private HttpResponse executeRequest(String headerName, String headerValu for (int attempt = 0; attempt < retryLimit; attempt++) { try { response = this.httpClient.send(request, HttpResponse.BodyHandlers.ofString()); - switch (response.statusCode()) { - case HttpStatusCodes.STATUS_CODE_OK: - case HttpStatusCodes.STATUS_CODE_UNAUTHORIZED: - return response; + if (response.statusCode() == HttpStatusCodes.STATUS_CODE_OK + || response.statusCode() == HttpStatusCodes.STATUS_CODE_UNAUTHORIZED) { + return response; } } catch (HttpTimeoutException exception) { System.out.println(String.format("TimeoutException: %s", exception)); From 44b3db3b2305cdb3e369f7430c7059d818ddb9fa Mon Sep 17 00:00:00 2001 From: FRANCISCO JAVIER ALARCON ESPARZA Date: Tue, 29 Jul 2025 23:05:26 +0000 Subject: [PATCH 03/10] fix comments from gemini --- .../src/main/java/com/example/serviceauth/Authentication.java | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/run/service-auth/src/main/java/com/example/serviceauth/Authentication.java b/run/service-auth/src/main/java/com/example/serviceauth/Authentication.java index 9c288484e31..4af80c0fc9f 100644 --- a/run/service-auth/src/main/java/com/example/serviceauth/Authentication.java +++ b/run/service-auth/src/main/java/com/example/serviceauth/Authentication.java @@ -31,7 +31,6 @@ import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.stereotype.Service; -import org.springframework.web.bind.annotation.CrossOrigin; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestHeader; import org.springframework.web.bind.annotation.RestController; @@ -39,14 +38,13 @@ @SpringBootApplication public class Authentication { @RestController - @CrossOrigin(exposedHeaders = "*", allowedHeaders = "*") class AuthenticationController { @Autowired private AuthenticationService authService; @GetMapping("/") public ResponseEntity getEmailFromAuthHeader( - @RequestHeader("X-Serverless-Authorization") String authHeader) { + @RequestHeader(value = "X-Serverless-Authorization", required = false) String authHeader) { String responseBody; if (authHeader == null) { responseBody = "Error verifying ID token: missing X-Serverless-Authorization header"; From ba0f479e380bdf7f515487996c78dacdfc037160 Mon Sep 17 00:00:00 2001 From: FRANCISCO JAVIER ALARCON ESPARZA Date: Wed, 30 Jul 2025 18:29:51 +0000 Subject: [PATCH 04/10] add crossorigin --- .../src/main/java/com/example/serviceauth/Authentication.java | 2 ++ 1 file changed, 2 insertions(+) diff --git a/run/service-auth/src/main/java/com/example/serviceauth/Authentication.java b/run/service-auth/src/main/java/com/example/serviceauth/Authentication.java index 4af80c0fc9f..2e8511dac44 100644 --- a/run/service-auth/src/main/java/com/example/serviceauth/Authentication.java +++ b/run/service-auth/src/main/java/com/example/serviceauth/Authentication.java @@ -31,6 +31,7 @@ import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.stereotype.Service; +import org.springframework.web.bind.annotation.CrossOrigin; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestHeader; import org.springframework.web.bind.annotation.RestController; @@ -38,6 +39,7 @@ @SpringBootApplication public class Authentication { @RestController + @CrossOrigin(exposedHeaders = "*", allowedHeaders = "*") class AuthenticationController { @Autowired private AuthenticationService authService; From 0f5df2dbc013d461ee16fe83bdb9625bec696eef Mon Sep 17 00:00:00 2001 From: FRANCISCO JAVIER ALARCON ESPARZA Date: Fri, 1 Aug 2025 19:57:33 +0000 Subject: [PATCH 05/10] fix issues with header --- .../java/com/example/serviceauth/Authentication.java | 12 +++++------- .../com/example/serviceauth/AuthenticationTests.java | 7 ++++--- 2 files changed, 9 insertions(+), 10 deletions(-) diff --git a/run/service-auth/src/main/java/com/example/serviceauth/Authentication.java b/run/service-auth/src/main/java/com/example/serviceauth/Authentication.java index 2e8511dac44..ef23d188cc5 100644 --- a/run/service-auth/src/main/java/com/example/serviceauth/Authentication.java +++ b/run/service-auth/src/main/java/com/example/serviceauth/Authentication.java @@ -27,7 +27,6 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; -import org.springframework.http.HttpHeaders; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.stereotype.Service; @@ -46,19 +45,17 @@ class AuthenticationController { @GetMapping("/") public ResponseEntity getEmailFromAuthHeader( - @RequestHeader(value = "X-Serverless-Authorization", required = false) String authHeader) { + @RequestHeader(value = "X-Authorization", required = false) String authHeader) { String responseBody; if (authHeader == null) { - responseBody = "Error verifying ID token: missing X-Serverless-Authorization header"; + responseBody = "Error verifying ID token: missing X-Authorization header"; return new ResponseEntity<>(responseBody, HttpStatus.UNAUTHORIZED); } String email = authService.parseAuthHeader(authHeader); if (email == null) { responseBody = "Unauthorized request. Please supply a valid bearer token."; - HttpHeaders headers = new HttpHeaders(); - headers.add("WWW-Authenticate", "Bearer"); - return new ResponseEntity<>(responseBody, headers, HttpStatus.UNAUTHORIZED); + return new ResponseEntity<>(responseBody, HttpStatus.UNAUTHORIZED); } responseBody = "Hello, " + email; @@ -95,7 +92,7 @@ public String parseAuthHeader(String authHeader) { Collection audience = Arrays.asList(serviceUrl); // Validate and decode the ID token in the header. - if ("Bearer".equals(authType)) { + if ("bearer".equals(authType.toLowerCase())) { try { // Find more information about the verification process in: // https://developers.google.com/identity/sign-in/web/backend-auth#java @@ -119,6 +116,7 @@ public String parseAuthHeader(String authHeader) { } } catch (Exception exception) { System.out.println("Ivalid token: " + exception); + return null; } } else { System.out.println("Unhandled header format: " + authType); diff --git a/run/service-auth/src/test/java/com/example/serviceauth/AuthenticationTests.java b/run/service-auth/src/test/java/com/example/serviceauth/AuthenticationTests.java index 36494aa3ee4..8e26703f80c 100644 --- a/run/service-auth/src/test/java/com/example/serviceauth/AuthenticationTests.java +++ b/run/service-auth/src/test/java/com/example/serviceauth/AuthenticationTests.java @@ -180,17 +180,18 @@ private HttpResponse executeRequest(String headerName, String headerValu @Test public void testValidToken() throws Exception { String token = getGoogleIdToken(); - HttpResponse response = executeRequest("X-Serverless-Authorization", "Bearer " + token); + HttpResponse response = executeRequest("X-Authorization", "bearer " + token); assertTrue(response != null); assertTrue(response.statusCode() == HttpStatusCodes.STATUS_CODE_OK); assertTrue(response.body().contains("Hello,")); + assertTrue(response.body().contains("@")); } @Test public void testInvalidToken() throws Exception { String token = "invalid_token"; - HttpResponse response = executeRequest("X-Serverless-Authorization", "Bearer " + token); + HttpResponse response = executeRequest("X-Authorization", "bearer " + token); assertTrue(response != null); assertTrue(response.statusCode() == HttpStatusCodes.STATUS_CODE_UNAUTHORIZED); @@ -203,6 +204,6 @@ public void testAnonymousRequest() throws Exception { assertTrue(response != null); assertTrue(response.statusCode() == HttpStatusCodes.STATUS_CODE_UNAUTHORIZED); - assertTrue(response.body().contains("missing X-Serverless-Authorization header")); + assertTrue(response.body().contains("missing X-Authorization header")); } } From bbc57aa65ed8a944bd20bcbea344e921579d3380 Mon Sep 17 00:00:00 2001 From: FRANCISCO JAVIER ALARCON ESPARZA Date: Fri, 1 Aug 2025 20:14:09 +0000 Subject: [PATCH 06/10] improve testing times --- .../serviceauth/AuthenticationTests.java | 63 ++++++++++--------- 1 file changed, 32 insertions(+), 31 deletions(-) diff --git a/run/service-auth/src/test/java/com/example/serviceauth/AuthenticationTests.java b/run/service-auth/src/test/java/com/example/serviceauth/AuthenticationTests.java index 8e26703f80c..0456abc7d95 100644 --- a/run/service-auth/src/test/java/com/example/serviceauth/AuthenticationTests.java +++ b/run/service-auth/src/test/java/com/example/serviceauth/AuthenticationTests.java @@ -33,8 +33,8 @@ import java.util.Arrays; import java.util.List; import java.util.UUID; -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.Test; import org.springframework.boot.test.context.SpringBootTest; @@ -43,64 +43,64 @@ public class AuthenticationTests { private static String PROJECT_ID = System.getenv("GOOGLE_CLOUD_PROJECT"); private static String REGION = "us-central1"; - private String projectNumber; - private String serviceUrl; - private String serviceName; - private HttpClient httpClient; - - @BeforeEach - public void setUp() { - this.projectNumber = getProjectNumber(); - this.serviceName = generateServiceName(); - this.serviceUrl = generateServiceUrl(); - this.deployService(); - - this.httpClient = HttpClient.newHttpClient(); + private static String PROJECT_NUMBER; + private static String SERVICE_URL; + private static String SERVICE_NAME; + private static HttpClient HTTP_CLIENT; + + @BeforeAll + public static void setUp() { + PROJECT_NUMBER = getPROJECT_NUMBER(); + SERVICE_NAME = generateServiceName(); + SERVICE_URL = generateServiceUrl(); + deployService(); + + HTTP_CLIENT = HttpClient.newHttpClient(); } - @AfterEach - public void tearDown() { - this.deleteService(); + @AfterAll + public static void tearDown() { + deleteService(); } - private String getProjectNumber() { + private static String getPROJECT_NUMBER() { return getOutputFromCommand( List.of("gcloud", "projects", "describe", PROJECT_ID, "--format=value(projectNumber)")); } - private String generateServiceName() { + private static String generateServiceName() { return String.format("receive-java-%s", UUID.randomUUID().toString().substring(0, 8)); } - private String generateServiceUrl() { - return String.format("https://%s-%s.%s.run.app", this.serviceName, this.projectNumber, REGION); + private static String generateServiceUrl() { + return String.format("https://%s-%s.%s.run.app", SERVICE_NAME, PROJECT_NUMBER, REGION); } - private String deployService() { + private static String deployService() { return getOutputFromCommand( List.of( "gcloud", "run", "deploy", - serviceName, + SERVICE_NAME, "--project", PROJECT_ID, "--source", ".", "--region=" + REGION, "--allow-unauthenticated", - "--set-env-vars=SERVICE_URL=" + serviceUrl, + "--set-env-vars=SERVICE_URL=" + SERVICE_URL, "--quiet")); } - private String deleteService() { + private static String deleteService() { return getOutputFromCommand( List.of( "gcloud", "run", "services", "delete", - serviceName, + SERVICE_NAME, "--project", PROJECT_ID, "--async", @@ -108,7 +108,7 @@ private String deleteService() { "--quiet")); } - private String getOutputFromCommand(List command) { + private static String getOutputFromCommand(List command) { try { ProcessBuilder processBuilder = new ProcessBuilder(command); @@ -131,7 +131,7 @@ private String getGoogleIdToken() { IdTokenCredentials idTokenCredentials = IdTokenCredentials.newBuilder() .setIdTokenProvider((IdTokenProvider) googleCredentials) - .setTargetAudience(serviceUrl) + .setTargetAudience(SERVICE_URL) .setOptions(Arrays.asList(Option.FORMAT_FULL, Option.LICENSES_TRUE)) .build(); @@ -142,7 +142,8 @@ private String getGoogleIdToken() { } private HttpResponse executeRequest(String headerName, String headerValue) { - HttpRequest.Builder requestBuilder = HttpRequest.newBuilder().uri(URI.create(serviceUrl)).GET(); + HttpRequest.Builder requestBuilder = + HttpRequest.newBuilder().uri(URI.create(SERVICE_URL)).GET(); if (headerName != null) { requestBuilder = requestBuilder.header(headerName, headerValue); } @@ -153,7 +154,7 @@ private HttpResponse executeRequest(String headerName, String headerValu for (int attempt = 0; attempt < retryLimit; attempt++) { try { - response = this.httpClient.send(request, HttpResponse.BodyHandlers.ofString()); + response = HTTP_CLIENT.send(request, HttpResponse.BodyHandlers.ofString()); if (response.statusCode() == HttpStatusCodes.STATUS_CODE_OK || response.statusCode() == HttpStatusCodes.STATUS_CODE_UNAUTHORIZED) { return response; From e984e4964c2e74a959c764624677f0312897b6e7 Mon Sep 17 00:00:00 2001 From: FRANCISCO JAVIER ALARCON ESPARZA Date: Fri, 1 Aug 2025 21:20:29 +0000 Subject: [PATCH 07/10] remove crossorigin --- .../src/main/java/com/example/serviceauth/Authentication.java | 2 -- 1 file changed, 2 deletions(-) diff --git a/run/service-auth/src/main/java/com/example/serviceauth/Authentication.java b/run/service-auth/src/main/java/com/example/serviceauth/Authentication.java index ef23d188cc5..a41bae4648b 100644 --- a/run/service-auth/src/main/java/com/example/serviceauth/Authentication.java +++ b/run/service-auth/src/main/java/com/example/serviceauth/Authentication.java @@ -30,7 +30,6 @@ import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.stereotype.Service; -import org.springframework.web.bind.annotation.CrossOrigin; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestHeader; import org.springframework.web.bind.annotation.RestController; @@ -38,7 +37,6 @@ @SpringBootApplication public class Authentication { @RestController - @CrossOrigin(exposedHeaders = "*", allowedHeaders = "*") class AuthenticationController { @Autowired private AuthenticationService authService; From 6c7bc3a6997cafaa858572e04becef5aca4c6c31 Mon Sep 17 00:00:00 2001 From: FRANCISCO JAVIER ALARCON ESPARZA Date: Tue, 12 Aug 2025 22:21:45 +0000 Subject: [PATCH 08/10] solve comments from reviewer --- run/service-auth/pom.xml | 4 +- .../example/serviceauth/Authentication.java | 1 + .../serviceauth/AuthenticationTests.java | 54 +++++++++---------- 3 files changed, 27 insertions(+), 32 deletions(-) diff --git a/run/service-auth/pom.xml b/run/service-auth/pom.xml index 103c2685413..8bdb845ee88 100644 --- a/run/service-auth/pom.xml +++ b/run/service-auth/pom.xml @@ -24,7 +24,7 @@ limitations under the License. com.google.cloud.samples shared-configuration - 1.2.0 + 1.2.2 @@ -107,4 +107,4 @@ limitations under the License. - \ No newline at end of file + diff --git a/run/service-auth/src/main/java/com/example/serviceauth/Authentication.java b/run/service-auth/src/main/java/com/example/serviceauth/Authentication.java index a41bae4648b..6fa0605f09d 100644 --- a/run/service-auth/src/main/java/com/example/serviceauth/Authentication.java +++ b/run/service-auth/src/main/java/com/example/serviceauth/Authentication.java @@ -108,6 +108,7 @@ public String parseAuthHeader(String authHeader) { // https://cloud.google.com/java/docs/reference/google-api-client/latest/com.google.api.client.googleapis.auth.oauth2.GoogleIdToken.Payload GoogleIdToken.Payload payload = googleIdToken.getPayload(); if (payload.getEmailVerified()) { + System.out.println("Email verified: " + payload.getEmail()); return payload.getEmail(); } System.out.println("Invalid token. Email wasn't verified."); diff --git a/run/service-auth/src/test/java/com/example/serviceauth/AuthenticationTests.java b/run/service-auth/src/test/java/com/example/serviceauth/AuthenticationTests.java index 0456abc7d95..e209f78ed24 100644 --- a/run/service-auth/src/test/java/com/example/serviceauth/AuthenticationTests.java +++ b/run/service-auth/src/test/java/com/example/serviceauth/AuthenticationTests.java @@ -49,8 +49,8 @@ public class AuthenticationTests { private static HttpClient HTTP_CLIENT; @BeforeAll - public static void setUp() { - PROJECT_NUMBER = getPROJECT_NUMBER(); + public static void setUp() throws InterruptedException, IOException { + PROJECT_NUMBER = getProjectNumber(); SERVICE_NAME = generateServiceName(); SERVICE_URL = generateServiceUrl(); deployService(); @@ -59,11 +59,11 @@ public static void setUp() { } @AfterAll - public static void tearDown() { + public static void tearDown() throws InterruptedException, IOException { deleteService(); } - private static String getPROJECT_NUMBER() { + private static String getProjectNumber() throws InterruptedException, IOException { return getOutputFromCommand( List.of("gcloud", "projects", "describe", PROJECT_ID, "--format=value(projectNumber)")); } @@ -76,7 +76,7 @@ private static String generateServiceUrl() { return String.format("https://%s-%s.%s.run.app", SERVICE_NAME, PROJECT_NUMBER, REGION); } - private static String deployService() { + private static String deployService() throws InterruptedException, IOException { return getOutputFromCommand( List.of( "gcloud", @@ -93,7 +93,7 @@ private static String deployService() { "--quiet")); } - private static String deleteService() { + private static String deleteService() throws InterruptedException, IOException { return getOutputFromCommand( List.of( "gcloud", @@ -108,37 +108,31 @@ private static String deleteService() { "--quiet")); } - private static String getOutputFromCommand(List command) { - try { - ProcessBuilder processBuilder = new ProcessBuilder(command); + private static String getOutputFromCommand(List command) + throws InterruptedException, IOException { - Process process = processBuilder.start(); - String output = - new String(process.getInputStream().readAllBytes(), StandardCharsets.UTF_8).strip(); + ProcessBuilder processBuilder = new ProcessBuilder(command); - process.waitFor(); + Process process = processBuilder.start(); + String output = + new String(process.getInputStream().readAllBytes(), StandardCharsets.UTF_8).strip(); - return output; - } catch (InterruptedException | IOException exception) { - return String.format("Exception: %s", exception); - } + process.waitFor(); + + return output; } - private String getGoogleIdToken() { - try { - GoogleCredentials googleCredentials = GoogleCredentials.getApplicationDefault(); + private String getGoogleIdToken() throws IOException { + GoogleCredentials googleCredentials = GoogleCredentials.getApplicationDefault(); - IdTokenCredentials idTokenCredentials = - IdTokenCredentials.newBuilder() - .setIdTokenProvider((IdTokenProvider) googleCredentials) - .setTargetAudience(SERVICE_URL) - .setOptions(Arrays.asList(Option.FORMAT_FULL, Option.LICENSES_TRUE)) - .build(); + IdTokenCredentials idTokenCredentials = + IdTokenCredentials.newBuilder() + .setIdTokenProvider((IdTokenProvider) googleCredentials) + .setTargetAudience(SERVICE_URL) + .setOptions(Arrays.asList(Option.FORMAT_FULL, Option.LICENSES_TRUE)) + .build(); - return idTokenCredentials.refreshAccessToken().getTokenValue(); - } catch (IOException exception) { - return "error_generating_token"; - } + return idTokenCredentials.refreshAccessToken().getTokenValue(); } private HttpResponse executeRequest(String headerName, String headerValue) { From 967bde36c78e61ae34211febc13720569882fae3 Mon Sep 17 00:00:00 2001 From: FRANCISCO JAVIER ALARCON ESPARZA Date: Tue, 2 Sep 2025 23:45:39 +0000 Subject: [PATCH 09/10] add waitForServer method --- .../serviceauth/AuthenticationTests.java | 64 +++++++++++++------ 1 file changed, 44 insertions(+), 20 deletions(-) diff --git a/run/service-auth/src/test/java/com/example/serviceauth/AuthenticationTests.java b/run/service-auth/src/test/java/com/example/serviceauth/AuthenticationTests.java index e209f78ed24..215cfac5663 100644 --- a/run/service-auth/src/test/java/com/example/serviceauth/AuthenticationTests.java +++ b/run/service-auth/src/test/java/com/example/serviceauth/AuthenticationTests.java @@ -56,6 +56,7 @@ public static void setUp() throws InterruptedException, IOException { deployService(); HTTP_CLIENT = HttpClient.newHttpClient(); + waitForService(); } @AfterAll @@ -122,29 +123,37 @@ private static String getOutputFromCommand(List command) return output; } - private String getGoogleIdToken() throws IOException { - GoogleCredentials googleCredentials = GoogleCredentials.getApplicationDefault(); - - IdTokenCredentials idTokenCredentials = - IdTokenCredentials.newBuilder() - .setIdTokenProvider((IdTokenProvider) googleCredentials) - .setTargetAudience(SERVICE_URL) - .setOptions(Arrays.asList(Option.FORMAT_FULL, Option.LICENSES_TRUE)) - .build(); - - return idTokenCredentials.refreshAccessToken().getTokenValue(); + private static void waitForService() { + HttpResponse response = null; + int waitingTimeInSeconds = 1; + int retryTimeLimitInSeconds = 32; + while (waitingTimeInSeconds <= retryTimeLimitInSeconds) { + response = executeRequest(buildRequest(null, null)); + if (response != null) { + break; + } + waitingTimeInSeconds *= 2; + try { + Thread.sleep(waitingTimeInSeconds * 1000); + } catch (Exception e) { + Thread.currentThread().interrupt(); + } + } } - private HttpResponse executeRequest(String headerName, String headerValue) { + private static HttpRequest buildRequest(String headerName, String headerValue) { HttpRequest.Builder requestBuilder = HttpRequest.newBuilder().uri(URI.create(SERVICE_URL)).GET(); if (headerName != null) { requestBuilder = requestBuilder.header(headerName, headerValue); } - HttpRequest request = requestBuilder.build(); + return requestBuilder.build(); + } + + private static HttpResponse executeRequest(HttpRequest request) { HttpResponse response = null; - int retryDelay = 2000; - int retryLimit = 3; + int retryDelay = 3000; + int retryLimit = 5; for (int attempt = 0; attempt < retryLimit; attempt++) { try { @@ -165,17 +174,30 @@ private HttpResponse executeRequest(String headerName, String headerValu Thread.sleep(retryDelay); } catch (InterruptedException exception) { Thread.currentThread().interrupt(); - return null; } } return null; } + private String getGoogleIdToken() throws IOException { + GoogleCredentials googleCredentials = GoogleCredentials.getApplicationDefault(); + + IdTokenCredentials idTokenCredentials = + IdTokenCredentials.newBuilder() + .setIdTokenProvider((IdTokenProvider) googleCredentials) + .setTargetAudience(SERVICE_URL) + .setOptions(Arrays.asList(Option.FORMAT_FULL, Option.LICENSES_TRUE)) + .build(); + + return idTokenCredentials.refreshAccessToken().getTokenValue(); + } + @Test public void testValidToken() throws Exception { String token = getGoogleIdToken(); - HttpResponse response = executeRequest("X-Authorization", "bearer " + token); + HttpRequest request = buildRequest("Authorization", "bearer " + token); + HttpResponse response = executeRequest(request); assertTrue(response != null); assertTrue(response.statusCode() == HttpStatusCodes.STATUS_CODE_OK); @@ -186,7 +208,8 @@ public void testValidToken() throws Exception { @Test public void testInvalidToken() throws Exception { String token = "invalid_token"; - HttpResponse response = executeRequest("X-Authorization", "bearer " + token); + HttpRequest request = buildRequest("Authorization", "bearer " + token); + HttpResponse response = executeRequest(request); assertTrue(response != null); assertTrue(response.statusCode() == HttpStatusCodes.STATUS_CODE_UNAUTHORIZED); @@ -195,10 +218,11 @@ public void testInvalidToken() throws Exception { @Test public void testAnonymousRequest() throws Exception { - HttpResponse response = executeRequest(null, null); + HttpRequest request = buildRequest(null, null); + HttpResponse response = executeRequest(request); assertTrue(response != null); assertTrue(response.statusCode() == HttpStatusCodes.STATUS_CODE_UNAUTHORIZED); - assertTrue(response.body().contains("missing X-Authorization header")); + assertTrue(response.body().contains("missing Authorization header")); } } From 2cc8bc285f9d1424e617415f9444487d2cfb3ed6 Mon Sep 17 00:00:00 2001 From: FRANCISCO JAVIER ALARCON ESPARZA Date: Wed, 3 Sep 2025 00:47:18 +0000 Subject: [PATCH 10/10] Use Authorization header --- .../example/serviceauth/Authentication.java | 67 +++++++++---------- 1 file changed, 33 insertions(+), 34 deletions(-) diff --git a/run/service-auth/src/main/java/com/example/serviceauth/Authentication.java b/run/service-auth/src/main/java/com/example/serviceauth/Authentication.java index 6fa0605f09d..f92ff6437a9 100644 --- a/run/service-auth/src/main/java/com/example/serviceauth/Authentication.java +++ b/run/service-auth/src/main/java/com/example/serviceauth/Authentication.java @@ -24,12 +24,11 @@ import com.google.api.client.json.gson.GsonFactory; import java.util.Arrays; import java.util.Collection; -import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.http.HttpHeaders; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; -import org.springframework.stereotype.Service; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestHeader; import org.springframework.web.bind.annotation.RestController; @@ -39,21 +38,23 @@ public class Authentication { @RestController class AuthenticationController { - @Autowired private AuthenticationService authService; + private final AuthenticationService authService = new AuthenticationService(); @GetMapping("/") public ResponseEntity getEmailFromAuthHeader( - @RequestHeader(value = "X-Authorization", required = false) String authHeader) { + @RequestHeader(value = "Authorization", required = false) String authHeader) { String responseBody; if (authHeader == null) { - responseBody = "Error verifying ID token: missing X-Authorization header"; + responseBody = "Error verifying ID token: missing Authorization header"; return new ResponseEntity<>(responseBody, HttpStatus.UNAUTHORIZED); } String email = authService.parseAuthHeader(authHeader); if (email == null) { responseBody = "Unauthorized request. Please supply a valid bearer token."; - return new ResponseEntity<>(responseBody, HttpStatus.UNAUTHORIZED); + HttpHeaders headers = new HttpHeaders(); + headers.add("WWW-Authenticate", "Bearer"); + return new ResponseEntity<>(responseBody, headers, HttpStatus.UNAUTHORIZED); } responseBody = "Hello, " + email; @@ -61,7 +62,6 @@ public ResponseEntity getEmailFromAuthHeader( } } - @Service public class AuthenticationService { /* * Parse the authorization header, validate and decode the Bearer token. @@ -82,6 +82,11 @@ public String parseAuthHeader(String authHeader) { } String authType = authHeaderStrings[0]; String tokenValue = authHeaderStrings[1]; + // Validate and decode the ID token in the header. + if (!"bearer".equals(authType.toLowerCase())) { + System.out.println("Unhandled header format: " + authType); + return null; + } // Get the service URL from the environment variable // set at the time of deployment. @@ -89,36 +94,30 @@ public String parseAuthHeader(String authHeader) { // Define the expected audience as the Service Base URL. Collection audience = Arrays.asList(serviceUrl); - // Validate and decode the ID token in the header. - if ("bearer".equals(authType.toLowerCase())) { - try { - // Find more information about the verification process in: - // https://developers.google.com/identity/sign-in/web/backend-auth#java - // https://cloud.google.com/java/docs/reference/google-api-client/latest/com.google.api.client.googleapis.auth.oauth2.GoogleIdTokenVerifier - GoogleIdTokenVerifier verifier = - new GoogleIdTokenVerifier.Builder(new ApacheHttpTransport(), new GsonFactory()) - .setAudience(audience) - .build(); - GoogleIdToken googleIdToken = verifier.verify(tokenValue); + try { + // Find more information about the verification process in: + // https://developers.google.com/identity/sign-in/web/backend-auth#java + // https://cloud.google.com/java/docs/reference/google-api-client/latest/com.google.api.client.googleapis.auth.oauth2.GoogleIdTokenVerifier + GoogleIdTokenVerifier verifier = + new GoogleIdTokenVerifier.Builder(new ApacheHttpTransport(), new GsonFactory()) + .setAudience(audience) + .build(); + GoogleIdToken googleIdToken = verifier.verify(tokenValue); - if (googleIdToken != null) { - // More info about the structure for the decoded ID Token here: - // https://cloud.google.com/docs/authentication/token-types#id - // https://cloud.google.com/java/docs/reference/google-api-client/latest/com.google.api.client.googleapis.auth.oauth2.GoogleIdToken - // https://cloud.google.com/java/docs/reference/google-api-client/latest/com.google.api.client.googleapis.auth.oauth2.GoogleIdToken.Payload - GoogleIdToken.Payload payload = googleIdToken.getPayload(); - if (payload.getEmailVerified()) { - System.out.println("Email verified: " + payload.getEmail()); - return payload.getEmail(); - } - System.out.println("Invalid token. Email wasn't verified."); - } - } catch (Exception exception) { - System.out.println("Ivalid token: " + exception); + // More info about the structure for the decoded ID Token here: + // https://cloud.google.com/docs/authentication/token-types#id + // https://cloud.google.com/java/docs/reference/google-api-client/latest/com.google.api.client.googleapis.auth.oauth2.GoogleIdToken + // https://cloud.google.com/java/docs/reference/google-api-client/latest/com.google.api.client.googleapis.auth.oauth2.GoogleIdToken.Payload + GoogleIdToken.Payload payload = googleIdToken.getPayload(); + if (!payload.getEmailVerified()) { + System.out.println("Invalid token. Email wasn't verified."); return null; } - } else { - System.out.println("Unhandled header format: " + authType); + System.out.println("Email verified: " + payload.getEmail()); + return payload.getEmail(); + + } catch (Exception exception) { + System.out.println("Ivalid token: " + exception); } return null; }