diff --git a/appengine-java21/appidentity/README.md b/appengine-java21/appidentity/README.md new file mode 100644 index 00000000000..44e79dbb91e --- /dev/null +++ b/appengine-java21/appidentity/README.md @@ -0,0 +1,22 @@ +# App Identity sample for Google App Engine + + +Open in Cloud Shell + + +This sample demonstrates how to use the [App Identity API][appid] on [Google App +Engine][ae-docs]. + +[appid]: https://cloud.google.com/appengine/docs/java/appidentity/ +[ae-docs]: https://cloud.google.com/appengine/docs/java/ + +## Running locally +This example uses the +[Maven Cloud SDK based plugin](https://cloud.google.com/appengine/docs/java/tools/using-maven). +To run this sample locally: + + $ mvn appengine:run + +## Deploying + + $ mvn clean package appengine:deploy diff --git a/appengine-java21/appidentity/pom.xml b/appengine-java21/appidentity/pom.xml new file mode 100644 index 00000000000..18fcb73866c --- /dev/null +++ b/appengine-java21/appidentity/pom.xml @@ -0,0 +1,138 @@ + + + 4.0.0 + war + 1.0-SNAPSHOT + com.example.appengine + appengine-appidentity-j21 + + + + com.google.cloud.samples + shared-configuration + 1.2.0 + + + + 21 + 21 + + + + + + libraries-bom + com.google.cloud + import + pom + 26.28.0 + + + + + + + com.google.appengine + appengine-api-1.0-sdk + 2.0.23 + + + + com.google.guava + guava + + + + org.json + json + 20231013 + + + + jakarta.servlet + jakarta.servlet-api + 6.0.0 + provided + + + + + com.google.appengine + appengine-api-stubs + 2.0.23 + test + + + com.google.appengine + appengine-tools-sdk + 2.0.23 + test + + + + junit + junit + 4.13.2 + test + + + org.mockito + mockito-core + 4.11.0 + test + + + com.google.appengine + appengine-testing + 2.0.23 + test + + + com.google.truth + truth + 1.1.5 + test + + + + + ${project.build.directory}/${project.build.finalName}/WEB-INF/classes + + + org.apache.maven.plugins + maven-war-plugin + 3.4.0 + + + com.google.cloud.tools + appengine-maven-plugin + 2.5.0 + + GCLOUD_CONFIG + GCLOUD_CONFIG + true + true + + + + + diff --git a/appengine-java21/appidentity/src/main/java/com/example/appengine/appidentity/IdentityServlet.java b/appengine-java21/appidentity/src/main/java/com/example/appengine/appidentity/IdentityServlet.java new file mode 100644 index 00000000000..f27495ab738 --- /dev/null +++ b/appengine-java21/appidentity/src/main/java/com/example/appengine/appidentity/IdentityServlet.java @@ -0,0 +1,44 @@ +/* + * Copyright 2015 Google Inc. + * + * 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.appengine.appidentity; + +import com.google.apphosting.api.ApiProxy; +import java.io.IOException; +import javax.servlet.annotation.WebServlet; +import javax.servlet.http.HttpServlet; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; + +@SuppressWarnings("serial") +// With @WebServlet annotation the webapp/WEB-INF/web.xml is no longer required. +@WebServlet( + name = "appidentity", + description = "AppIdentity: Get the Host Name", + urlPatterns = "/appidentity/identity" +) +public class IdentityServlet extends HttpServlet { + private static final String DEFAULT_VERSION_HOSTNAME_ATTRIBUTE = "com.google.appengine.runtime.default_version_hostname"; + + @Override + public void doGet(HttpServletRequest req, HttpServletResponse resp) throws IOException { + resp.setContentType("text/plain"); + ApiProxy.Environment env = ApiProxy.getCurrentEnvironment(); + resp.getWriter().print("default_version_hostname: "); + resp.getWriter() + .println(env.getAttributes().get(DEFAULT_VERSION_HOSTNAME_ATTRIBUTE)); + } +} diff --git a/appengine-java21/appidentity/src/main/java/com/example/appengine/appidentity/SignForAppServlet.java b/appengine-java21/appidentity/src/main/java/com/example/appengine/appidentity/SignForAppServlet.java new file mode 100644 index 00000000000..2fe87ce2658 --- /dev/null +++ b/appengine-java21/appidentity/src/main/java/com/example/appengine/appidentity/SignForAppServlet.java @@ -0,0 +1,116 @@ +/* + * Copyright 2016 Google Inc. + * + * 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.appengine.appidentity; + +import com.google.appengine.api.appidentity.AppIdentityService; +import com.google.appengine.api.appidentity.AppIdentityServiceFactory; +import com.google.appengine.api.appidentity.PublicCertificate; +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.UnsupportedEncodingException; +import java.security.InvalidKeyException; +import java.security.NoSuchAlgorithmException; +import java.security.PublicKey; +import java.security.Signature; +import java.security.SignatureException; +import java.security.cert.Certificate; +import java.security.cert.CertificateException; +import java.security.cert.CertificateFactory; +import java.util.Arrays; +import java.util.Calendar; +import java.util.Collection; +import javax.servlet.annotation.WebServlet; +import javax.servlet.http.HttpServlet; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; + +@SuppressWarnings("serial") +// With @WebServlet annotation the webapp/WEB-INF/web.xml is no longer required. +@WebServlet( + name = "signforapp", + description = "AppIdentity: Sign 'abcdefg'", + urlPatterns = "/appidentity/sign" +) +public class SignForAppServlet extends HttpServlet { + + private final AppIdentityService appIdentity; + + public SignForAppServlet() { + appIdentity = AppIdentityServiceFactory.getAppIdentityService(); + } + + // [START gae_java8_app_identity_other_services] + // Note that the algorithm used by AppIdentity.signForApp() and + // getPublicCertificatesForApp() is "SHA256withRSA" + + private byte[] signBlob(byte[] blob) { + AppIdentityService.SigningResult result = appIdentity.signForApp(blob); + return result.getSignature(); + } + + private byte[] getPublicCertificate() throws UnsupportedEncodingException { + Collection certs = appIdentity.getPublicCertificatesForApp(); + PublicCertificate publicCert = certs.iterator().next(); + return publicCert.getX509CertificateInPemFormat().getBytes(java.nio.charset.StandardCharsets.UTF_8); + } + + private Certificate parsePublicCertificate(byte[] publicCert) + throws CertificateException, NoSuchAlgorithmException { + InputStream stream = new ByteArrayInputStream(publicCert); + CertificateFactory cf = CertificateFactory.getInstance("X.509"); + return cf.generateCertificate(stream); + } + + private boolean verifySignature(byte[] blob, byte[] blobSignature, PublicKey pk) + throws NoSuchAlgorithmException, InvalidKeyException, SignatureException { + Signature signature = Signature.getInstance("SHA256withRSA"); + signature.initVerify(pk); + signature.update(blob); + return signature.verify(blobSignature); + } + + private String simulateIdentityAssertion() + throws CertificateException, UnsupportedEncodingException, NoSuchAlgorithmException, + InvalidKeyException, SignatureException { + // Simulate the sending app. + String message = "abcdefg " + java.time.Instant.now().toString(); + byte[] blob = message.getBytes(); + byte[] blobSignature = signBlob(blob); + byte[] publicCert = getPublicCertificate(); + + // Simulate the receiving app, which gets the certificate, blob, and signature. + Certificate cert = parsePublicCertificate(publicCert); + PublicKey pk = cert.getPublicKey(); + boolean isValid = verifySignature(blob, blobSignature, pk); + + return String.format( + "isValid=%b for message: %s\n\tsignature: %s\n\tpublic cert: %s", + isValid, message, Arrays.toString(blobSignature), Arrays.toString(publicCert)); + } + // [END gae_java8_app_identity_other_services] + + @Override + public void doGet(HttpServletRequest req, HttpServletResponse resp) throws IOException { + resp.setContentType("text/plain"); + try { + resp.getWriter().println(simulateIdentityAssertion()); + } catch (Exception e) { + throw new javax.servlet.ServletException(e); + } + } +} diff --git a/appengine-java21/appidentity/src/main/java/com/example/appengine/appidentity/UrlShortener.java b/appengine-java21/appidentity/src/main/java/com/example/appengine/appidentity/UrlShortener.java new file mode 100644 index 00000000000..c6c6df50f9b --- /dev/null +++ b/appengine-java21/appidentity/src/main/java/com/example/appengine/appidentity/UrlShortener.java @@ -0,0 +1,78 @@ +/* + * Copyright 2016 Google Inc. + * + * 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.appengine.appidentity; + +import com.google.appengine.api.appidentity.AppIdentityService; +import com.google.appengine.api.appidentity.AppIdentityServiceFactory; +import com.google.common.io.CharStreams; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.io.OutputStreamWriter; +import java.net.HttpURLConnection; +import java.net.URL; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import org.json.JSONObject; +import org.json.JSONTokener; + +@SuppressWarnings("serial") +class UrlShortener { + // [START gae_java21_app_identity_google_apis] + + /** + * Returns a shortened URL by calling the Google URL Shortener API. + * + *

Note: Error handling elided for simplicity. + */ + public String createShortUrl(String longUrl) throws IOException { + ArrayList scopes = new ArrayList<>(); + scopes.add("https://www.googleapis.com/auth/urlshortener"); + final AppIdentityService appIdentity = AppIdentityServiceFactory.getAppIdentityService(); + final AppIdentityService.GetAccessTokenResult accessToken = appIdentity.getAccessToken(scopes); + // The token asserts the identity reported by appIdentity.getServiceAccountName() + JSONObject request = new JSONObject(); + request.put("longUrl", longUrl); + + URL url = new URL("https://www.googleapis.com/urlshortener/v1/url?pp=1"); + HttpURLConnection connection = (HttpURLConnection) url.openConnection(); + connection.setDoOutput(true); + connection.setRequestMethod("POST"); + connection.addRequestProperty("Content-Type", "application/json"); + connection.addRequestProperty("Authorization", "Bearer " + accessToken.getAccessToken()); + + OutputStreamWriter writer = new OutputStreamWriter(connection.getOutputStream(), StandardCharsets.UTF_8); + request.write(writer); + writer.close(); + + if (connection.getResponseCode() == HttpURLConnection.HTTP_OK) { + // Note: Should check the content-encoding. + // Any JSON parser can be used; this one is used for illustrative purposes. + JSONTokener responseTokens = new JSONTokener(connection.getInputStream()); + JSONObject response = new JSONObject(responseTokens); + return (String) response.get("id"); + } else { + try (InputStream s = connection.getErrorStream(); + InputStreamReader r = new InputStreamReader(s, StandardCharsets.UTF_8)) { + throw new RuntimeException( + String.format( + "got error (%d) response %s from %s", + connection.getResponseCode(), CharStreams.toString(r), connection.toString())); + } + } + } + // [START gae_java21_app_identity_google_apis] +} diff --git a/appengine-java21/appidentity/src/main/java/com/example/appengine/appidentity/UrlShortenerServlet.java b/appengine-java21/appidentity/src/main/java/com/example/appengine/appidentity/UrlShortenerServlet.java new file mode 100644 index 00000000000..41bf8aebf8d --- /dev/null +++ b/appengine-java21/appidentity/src/main/java/com/example/appengine/appidentity/UrlShortenerServlet.java @@ -0,0 +1,80 @@ +/* + * Copyright 2016 Google Inc. + * + * 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.appengine.appidentity; + +import java.io.IOException; +import java.io.PrintWriter; +import javax.servlet.annotation.WebServlet; +import javax.servlet.http.HttpServlet; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; + +@SuppressWarnings("serial") +// With @WebServlet annotation the webapp/WEB-INF/web.xml is no longer required. +@WebServlet( + name = "UrlShortener", + description = "AppIdentity: Url Shortener", + urlPatterns = "/appidentity/shorten" +) +public class UrlShortenerServlet extends HttpServlet { + + private final UrlShortener shortener; + + public UrlShortenerServlet() { + shortener = new UrlShortener(); + } + + @Override + public void doGet(HttpServletRequest req, HttpServletResponse resp) throws IOException { + PrintWriter writer = resp.getWriter(); + writer.println(""); + writer.println(""); + writer.println( + "Asserting Identity to Google APIs - App Engine App Identity Example"); + writer.println("

"); + writer.println(""); + writer.println(""); + writer.println(""); + writer.println("
"); + } + + @Override + public void doPost(HttpServletRequest req, HttpServletResponse resp) throws IOException { + resp.setContentType("text/plain"); + String longUrl = req.getParameter("longUrl"); + if (longUrl == null) { + resp.sendError(HttpServletResponse.SC_BAD_REQUEST, "missing longUrl parameter"); + return; + } + + String shortUrl; + PrintWriter writer = resp.getWriter(); + try { + shortUrl = shortener.createShortUrl(longUrl); + } catch (Exception e) { + resp.setStatus(HttpServletResponse.SC_INTERNAL_SERVER_ERROR); + writer.println("error shortening URL: " + longUrl); + e.printStackTrace(); + return; + } + + writer.print("long URL: "); + writer.println(longUrl); + writer.print("short URL: "); + writer.println(shortUrl); + } +} diff --git a/appengine-java21/appidentity/src/main/webapp/WEB-INF/appengine-web.xml b/appengine-java21/appidentity/src/main/webapp/WEB-INF/appengine-web.xml new file mode 100644 index 00000000000..0f80dd41fc5 --- /dev/null +++ b/appengine-java21/appidentity/src/main/webapp/WEB-INF/appengine-web.xml @@ -0,0 +1,5 @@ + + + true + java21 + diff --git a/appengine-java21/appidentity/src/main/webapp/WEB-INF/web.xml b/appengine-java21/appidentity/src/main/webapp/WEB-INF/web.xml new file mode 100644 index 00000000000..21e14d226e3 --- /dev/null +++ b/appengine-java21/appidentity/src/main/webapp/WEB-INF/web.xml @@ -0,0 +1,8 @@ + + + + appidentity/identity + + diff --git a/appengine-java21/appidentity/src/test/java/com/example/appengine/appidentity/IdentityServletTest.java b/appengine-java21/appidentity/src/test/java/com/example/appengine/appidentity/IdentityServletTest.java new file mode 100644 index 00000000000..58c57c457c4 --- /dev/null +++ b/appengine-java21/appidentity/src/test/java/com/example/appengine/appidentity/IdentityServletTest.java @@ -0,0 +1,79 @@ +/* + * Copyright 2015 Google Inc. + * + * 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.appengine.appidentity; + +import static com.google.common.truth.Truth.assertWithMessage; +import static org.mockito.Mockito.when; + +import com.google.appengine.tools.development.testing.LocalServiceTestHelper; +import java.io.PrintWriter; +import java.io.StringWriter; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; + +/** + * Unit tests for {@link IdentityServlet}. + */ +@RunWith(JUnit4.class) +public class IdentityServletTest { + + // Set up a helper so that the ApiProxy returns a valid environment for local testing. + private final LocalServiceTestHelper helper = new LocalServiceTestHelper(); + + @Mock + private HttpServletRequest mockRequest; + @Mock + private HttpServletResponse mockResponse; + private StringWriter responseWriter; + private IdentityServlet servletUnderTest; + + @Before + public void setUp() throws Exception { + MockitoAnnotations.openMocks(this); + helper.setUp(); + + // Set up a fake HTTP response. + responseWriter = new StringWriter(); + when(mockResponse.getWriter()).thenReturn(new PrintWriter(responseWriter)); + + servletUnderTest = new IdentityServlet(); + } + + @After + public void tearDown() { + helper.tearDown(); + } + + @Test + public void doGet_defaultEnvironment_writesResponse() throws Exception { + servletUnderTest.doGet(mockRequest, mockResponse); + + // We don't have any guarantee over what the local App Engine environment returns for + // "com.google.appengine.runtime.default_version_hostname". Only assert that the response + // contains part of the string we have control over. + assertWithMessage("IdentityServlet response") + .that(responseWriter.toString()) + .contains("default_version_hostname:"); + } +} diff --git a/appengine-java21/appidentity/src/test/java/com/example/appengine/appidentity/SignForAppServletTest.java b/appengine-java21/appidentity/src/test/java/com/example/appengine/appidentity/SignForAppServletTest.java new file mode 100644 index 00000000000..cf2488005f1 --- /dev/null +++ b/appengine-java21/appidentity/src/test/java/com/example/appengine/appidentity/SignForAppServletTest.java @@ -0,0 +1,75 @@ +/* + * Copyright 2016 Google Inc. + * + * 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.appengine.appidentity; + +import static com.google.common.truth.Truth.assertWithMessage; +import static org.mockito.Mockito.when; + +import com.google.appengine.tools.development.testing.LocalServiceTestHelper; +import java.io.PrintWriter; +import java.io.StringWriter; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; + +/** + * Unit tests for {@link SignForAppServlet}. + */ +@RunWith(JUnit4.class) +public class SignForAppServletTest { + + private final LocalServiceTestHelper helper = new LocalServiceTestHelper(); + + @Mock + private HttpServletRequest mockRequest; + @Mock + private HttpServletResponse mockResponse; + private StringWriter responseWriter; + private SignForAppServlet servletUnderTest; + + @Before + public void setUp() throws Exception { + MockitoAnnotations.openMocks(this); + helper.setUp(); + + // Set up a fake HTTP response. + responseWriter = new StringWriter(); + when(mockResponse.getWriter()).thenReturn(new PrintWriter(responseWriter)); + + servletUnderTest = new SignForAppServlet(); + } + + @After + public void tearDown() { + helper.tearDown(); + } + + @Test + public void doGet_defaultEnvironment_successfullyVerifiesSignature() throws Exception { + servletUnderTest.doGet(mockRequest, mockResponse); + + assertWithMessage("SignForAppServlet response") + .that(responseWriter.toString()) + .contains("isValid=true for message: abcdefg"); + } +}