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..36494aa3ee4 --- /dev/null +++ b/run/service-auth/src/test/java/com/example/serviceauth/AuthenticationTests.java @@ -0,0 +1,208 @@ +/* + * 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()); + 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)); + 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")); + } +}