diff --git a/README.md b/README.md index 61281c1f694..f0920bdd291 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,7 @@ Technology Samples: * [Bigquery](bigquery) * [Datastore](datastore) * [Endpoints](endpoints) +* [Identity-Aware Proxy](iap) * [Key Management Service](kms) * [Logging](logging) * [Monitoring](monitoring) diff --git a/appengine/iap/README.md b/appengine/iap/README.md new file mode 100644 index 00000000000..39ca52ea4f7 --- /dev/null +++ b/appengine/iap/README.md @@ -0,0 +1,37 @@ +# Cloud Identity-Aware Proxy sample for Google App Engine + +This sample demonstrates how to use the [Cloud Identity-Aware Proxy][iap-docs] on [Google App +Engine][ae-docs]. + +[iap-docs]: https://cloud.google.com/iap/docs/ +[ae-docs]: https://cloud.google.com/appengine/docs/java/ + +## Setup + +Install the [Google Cloud SDK](https://cloud.google.com/sdk/) and run: +``` + gcloud init +``` +If this is your first time creating an App engine application: +``` + gcloud app create +``` + +## Running locally + +This application depends on being enabled behind an IAP, so this program should not be run locally. + +## Deploying + +- Deploy the application to the project + ``` + mvn clean appengine:deploy + ``` +- [Enable](https://cloud.google.com/iap/docs/app-engine-quickstart) Identity-Aware Proxy on the App Engine app. +- Add the email account you'll be running the test as to the Identity-Aware Proxy access list for the project. + +## Test + +Once deployed, access `https://your-project-id.appspot.com` . This should now prompt you to sign in for access. +Sign in with the email account that was added to the Identity-Aware proxy access list. +You should now see the jwt token that was received from the IAP server. diff --git a/appengine/iap/pom.xml b/appengine/iap/pom.xml new file mode 100644 index 00000000000..15b6cd930d8 --- /dev/null +++ b/appengine/iap/pom.xml @@ -0,0 +1,57 @@ + + + 4.0.0 + war + 1.0-SNAPSHOT + com.example.appengine + appengine-iap + + + com.google.cloud + appengine-doc-samples + 1.0.0 + .. + + + + javax.servlet + servlet-api + 2.5 + provided + + + + + ${project.build.directory}/${project.build.finalName}/WEB-INF/classes + + + org.apache.maven.plugins + 3.3 + maven-compiler-plugin + + 1.7 + 1.7 + + + + com.google.cloud.tools + appengine-maven-plugin + 1.3.1 + + + + diff --git a/appengine/iap/src/main/java/com/example/appengine/iap/JwtServlet.java b/appengine/iap/src/main/java/com/example/appengine/iap/JwtServlet.java new file mode 100644 index 00000000000..b6f7d95584f --- /dev/null +++ b/appengine/iap/src/main/java/com/example/appengine/iap/JwtServlet.java @@ -0,0 +1,34 @@ +/** + * Copyright 2017 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.iap; + +import java.io.IOException; +import javax.servlet.http.HttpServlet; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; + +/** + * Identity Aware Proxy (IAP) Test application to reflect jwt token issued by IAP. IAP must be + * enabled on application. {@see https://cloud.google.com/iap/docs/app-engine-quickstart} + */ +@SuppressWarnings("serial") +public class JwtServlet extends HttpServlet { + + private static final String IAP_JWT_HEADER = "x-goog-authenticated-user-jwt"; + + @Override + public void doGet(HttpServletRequest req, HttpServletResponse resp) throws IOException { + resp.getWriter().print(IAP_JWT_HEADER + ":" + req.getHeader(IAP_JWT_HEADER)); + } +} diff --git a/appengine/iap/src/main/webapp/WEB-INF/appengine-web.xml b/appengine/iap/src/main/webapp/WEB-INF/appengine-web.xml new file mode 100644 index 00000000000..b30cd159e63 --- /dev/null +++ b/appengine/iap/src/main/webapp/WEB-INF/appengine-web.xml @@ -0,0 +1,16 @@ + + + + true + diff --git a/appengine/iap/src/main/webapp/WEB-INF/web.xml b/appengine/iap/src/main/webapp/WEB-INF/web.xml new file mode 100644 index 00000000000..b4a68e55196 --- /dev/null +++ b/appengine/iap/src/main/webapp/WEB-INF/web.xml @@ -0,0 +1,14 @@ + + + + hello + com.example.appengine.iap.JwtServlet + + + hello + / + + diff --git a/appengine/pom.xml b/appengine/pom.xml index 151a4d840d9..2950bd7651c 100644 --- a/appengine/pom.xml +++ b/appengine/pom.xml @@ -61,6 +61,7 @@ guestbook-objectify helloworld helloworld-new-plugins + iap images logs mailgun diff --git a/iap/README.md b/iap/README.md new file mode 100644 index 00000000000..75238df3966 --- /dev/null +++ b/iap/README.md @@ -0,0 +1,34 @@ +# Cloud Identity-Aware Proxy Java Samples +Cloud Identity-Aware Proxy (Cloud IAP) lets you manage access to applications running in Compute Engine, App Engine standard environment, and Container Engine. Cloud IAP establishes a central authorization layer for applications accessed by HTTPS, enabling you to adopt an application-level access control model instead of relying on network-level firewalls. When you enable Cloud IAP, you must also use signed headers or the App Engine standard environment Users API to secure your app. + +## Setup +- A Google Cloud project with billing enabled +- A service account with private key credentials is required to create signed bearer tokens. + - [Create an App engine service account](https://cloud.google.com/docs/authentication#getting_credentials_for_server-centric_flow) and download the credentials file as JSON. + - Set the environment variable `GOOGLE_APPLICATION_CREDENTIALS` to point to the service account credentials file. +- Install the [Google Cloud SDK](https://cloud.google.com/sdk/) and run: +``` + gcloud init +``` + +## Description +- [BuildIapRequest.java](src/main/java/com/example/iap/BuildIapRequest.java) demonstrates how to set the +`Authorization : Bearer` header with a signed JWT token to authorize access to an IAP protected URL. +- [VerifyIapRequestHeader.java](src/main/java/com/example/iap/VerifyIapRequestHeader.java) demonstrates how to +verify the JWT token in an incoming request to an IAP protected resource. + +## Testing +- Deploy the [demo app engine application](../appengine/iap/README.md). This application will return the JWT token to an authorized incoming request. +It will be used to test both the authorization of an incoming request to an IAP protected resource and the JWT token returned from IAP. +- [Enable](https://cloud.google.com/iap/docs/app-engine-quickstart) Identity-Aware Proxy on the App Engine app. +- Add the service account email to the Identity-Aware Proxy access list for the project. +- Set the environment variable `IAP_PROTECTED_URL` to point to `https://your-project-id.appspot.com` +- Run the integration test: +``` + mvn -Dtest=com.example.iap.BuildAndVerifyIapRequestIT verify +``` + +## References +[JWT library for Java](https://github.com/auth0/java-jwt) +[Cloud IAP docs](https://cloud.google.com/iap/docs/) +[Service account credentials](https://cloud.google.com/docs/authentication#getting_credentials_for_server-centric_flow) diff --git a/iap/pom.xml b/iap/pom.xml new file mode 100644 index 00000000000..4f01bf5fdde --- /dev/null +++ b/iap/pom.xml @@ -0,0 +1,69 @@ + + + + + 4.0.0 + jar + com.example + iap-samples + 1.0-SNAPSHOT + + + 1.8 + 1.8 + UTF-8 + + + + + + com.fasterxml.jackson.core + jackson-core + 2.8.6 + + + + + + + javax.servlet + javax.servlet-api + 3.1.0 + + + + + com.google.auth + google-auth-library-oauth2-http + 0.6.0 + + + com.auth0 + java-jwt + 3.2.0 + + + + + + junit + junit + 4.12 + + + + diff --git a/iap/src/main/java/com/example/iap/BuildIapRequest.java b/iap/src/main/java/com/example/iap/BuildIapRequest.java new file mode 100644 index 00000000000..c5467f37d62 --- /dev/null +++ b/iap/src/main/java/com/example/iap/BuildIapRequest.java @@ -0,0 +1,135 @@ +/** + * Copyright 2017 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.iap; + +import com.auth0.jwt.JWT; +import com.auth0.jwt.algorithms.Algorithm; +import com.google.api.client.http.GenericUrl; +import com.google.api.client.http.HttpHeaders; +import com.google.api.client.http.HttpRequest; +import com.google.api.client.http.HttpRequestFactory; +import com.google.api.client.http.HttpResponse; +import com.google.api.client.http.HttpTransport; +import com.google.api.client.http.UrlEncodedContent; +import com.google.api.client.http.javanet.NetHttpTransport; +import com.google.api.client.json.JsonObjectParser; +import com.google.api.client.json.jackson2.JacksonFactory; +import com.google.api.client.util.GenericData; +import com.google.auth.oauth2.GoogleCredentials; +import com.google.auth.oauth2.ServiceAccountCredentials; +import java.io.IOException; +import java.net.URL; +import java.security.interfaces.RSAPrivateKey; +import java.time.Clock; +import java.time.Instant; +import java.util.Collections; +import java.util.Date; + +public class BuildIapRequest { + // [START generate_iap_request] + private static final String IAM_SCOPE = "https://www.googleapis.com/auth/iam"; + private static final String OAUTH_TOKEN_URI = "https://www.googleapis.com/oauth2/v4/token"; + private static final String JWT_BEARER_TOKEN_GRANT_TYPE = + "urn:ietf:params:oauth:grant-type:jwt-bearer"; + private static final long EXPIRATION_TIME_IN_SECONDS = 3600L; + + private static final HttpTransport httpTransport = new NetHttpTransport(); + + private static Clock clock = Clock.systemUTC(); + + private BuildIapRequest() {} + + private static String getBaseUrl(URL url) throws Exception { + String urlFilePath = url.getFile(); + int pathDelim = urlFilePath.lastIndexOf('/'); + String path = (pathDelim > 0) ? urlFilePath.substring(0, pathDelim) : ""; + return (url.getProtocol() + "://" + url.getHost() + path).trim(); + } + + private static ServiceAccountCredentials getCredentials() throws Exception { + GoogleCredentials credentials = + GoogleCredentials.getApplicationDefault().createScoped(Collections.singleton(IAM_SCOPE)); + // service account credentials are required to sign the jwt token + if (credentials == null || !(credentials instanceof ServiceAccountCredentials)) { + throw new Exception("Google credentials : service accounts credentials expected"); + } + return (ServiceAccountCredentials) credentials; + } + + private static String getSignedJWToken(ServiceAccountCredentials credentials, String baseUrl) + throws IOException { + Instant now = Instant.now(clock); + long expirationTime = now.getEpochSecond() + EXPIRATION_TIME_IN_SECONDS; + // generate jwt signed by service account + return JWT.create() + .withKeyId(credentials.getPrivateKeyId()) + .withAudience(OAUTH_TOKEN_URI) + .withIssuer(credentials.getClientEmail()) + .withSubject(credentials.getClientEmail()) + .withIssuedAt(Date.from(now)) + .withExpiresAt(Date.from(Instant.ofEpochSecond(expirationTime))) + .withClaim("target_audience", baseUrl) + .sign(Algorithm.RSA256(null, (RSAPrivateKey) credentials.getPrivateKey())); + } + + private static String getGoogleIdToken(String jwt) throws Exception { + final GenericData tokenRequest = + new GenericData().set("grant_type", JWT_BEARER_TOKEN_GRANT_TYPE).set("assertion", jwt); + final UrlEncodedContent content = new UrlEncodedContent(tokenRequest); + + final HttpRequestFactory requestFactory = httpTransport.createRequestFactory(); + + final HttpRequest request = + requestFactory + .buildPostRequest(new GenericUrl(OAUTH_TOKEN_URI), content) + .setParser(new JsonObjectParser(JacksonFactory.getDefaultInstance())); + + HttpResponse response; + String idToken = null; + response = request.execute(); + GenericData responseData = response.parseAs(GenericData.class); + idToken = (String) responseData.get("id_token"); + return idToken; + } + + public static HttpRequest buildIAPRequest(HttpRequest request) throws Exception { + // get service account credentials + ServiceAccountCredentials credentials = getCredentials(); + // get the base url of the request URL + String baseUrl = getBaseUrl(request.getUrl().toURL()); + String jwt = getSignedJWToken(credentials, baseUrl); + if (jwt == null) { + throw new Exception( + "Unable to create a signed jwt token for : " + + baseUrl + + "with issuer : " + + credentials.getClientEmail()); + } + + String idToken = getGoogleIdToken(jwt); + if (idToken == null) { + throw new Exception("Unable to retrieve open id token"); + } + + // Create an authorization header with bearer token + HttpHeaders httpHeaders = request.getHeaders().clone().setAuthorization("Bearer " + idToken); + + // create request with jwt authorization header + return httpTransport + .createRequestFactory() + .buildRequest(request.getRequestMethod(), request.getUrl(), request.getContent()) + .setHeaders(httpHeaders); + } + // [END generate_iap_request] +} diff --git a/iap/src/main/java/com/example/iap/VerifyIapRequestHeader.java b/iap/src/main/java/com/example/iap/VerifyIapRequestHeader.java new file mode 100644 index 00000000000..9e21fa79547 --- /dev/null +++ b/iap/src/main/java/com/example/iap/VerifyIapRequestHeader.java @@ -0,0 +1,163 @@ +/** + * Copyright 2017 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.iap; + +import com.auth0.jwt.JWT; +import com.auth0.jwt.JWTVerifier; +import com.auth0.jwt.algorithms.Algorithm; +import com.auth0.jwt.exceptions.JWTVerificationException; +import com.auth0.jwt.interfaces.DecodedJWT; +import com.auth0.jwt.interfaces.ECDSAKeyProvider; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.google.api.client.http.GenericUrl; +import com.google.api.client.http.HttpRequest; +import com.google.api.client.http.HttpResponse; +import com.google.api.client.http.HttpStatusCodes; +import com.google.api.client.http.javanet.NetHttpTransport; +import com.google.api.client.util.PemReader; +import com.google.api.client.util.PemReader.Section; +import java.io.IOException; +import java.io.StringReader; +import java.net.URL; +import java.security.KeyFactory; +import java.security.NoSuchAlgorithmException; +import java.security.PublicKey; +import java.security.interfaces.ECPrivateKey; +import java.security.interfaces.ECPublicKey; +import java.security.spec.InvalidKeySpecException; +import java.security.spec.X509EncodedKeySpec; +import java.util.HashMap; +import java.util.Map; + +/** Verify IAP authorization JWT token in incoming request. */ +public class VerifyIapRequestHeader { + // [START verify_iap_request] + private static final String PUBLIC_KEY_VERIFICATION_URL = + "https://www.gstatic.com/iap/verify/public_key"; + private static final String IAP_ISSUER_URL = "https://cloud.google.com/iap"; + + private final Map keyCache = new HashMap<>(); + private final ObjectMapper mapper = new ObjectMapper(); + private final TypeReference> typeRef = + new TypeReference>() {}; + + private ECDSAKeyProvider keyProvider = + new ECDSAKeyProvider() { + @Override + public ECPublicKey getPublicKeyById(String kid) { + ECPublicKey key = keyCache.get(kid); + if (key != null) { + return key; + } + try { + HttpRequest request = + new NetHttpTransport() + .createRequestFactory() + .buildGetRequest(new GenericUrl(PUBLIC_KEY_VERIFICATION_URL)); + HttpResponse response = request.execute(); + if (response.getStatusCode() != HttpStatusCodes.STATUS_CODE_OK) { + return null; + } + Map keys = mapper.readValue(response.parseAsString(), typeRef); + for (Map.Entry keyData : keys.entrySet()) { + if (!keyData.getKey().equals(kid)) { + continue; + } + key = getKey(keyData.getValue()); + if (key != null) { + keyCache.putIfAbsent(kid, key); + } + } + + } catch (IOException e) { + // ignore exception + } + + return key; + } + + @Override + public ECPrivateKey getPrivateKey() { + // ignore : only required for signing requests + return null; + } + + @Override + public String getPrivateKeyId() { + // ignore : only required for signing requests + return null; + } + }; + + private static String getBaseUrl(URL url) throws Exception { + String urlFilePath = url.getFile(); + int pathDelim = urlFilePath.lastIndexOf('/'); + String path = (pathDelim > 0) ? urlFilePath.substring(0, pathDelim) : ""; + return (url.getProtocol() + "://" + url.getHost() + path).trim(); + } + + DecodedJWT verifyJWTToken(HttpRequest request) throws Exception { + // Check for iap jwt header in incoming request + String jwtToken = + request.getHeaders().getFirstHeaderStringValue("x-goog-authenticated-user-jwt"); + if (jwtToken == null) { + return null; + } + String baseUrl = getBaseUrl(request.getUrl().toURL()); + return verifyJWTToken(jwtToken, baseUrl); + } + + DecodedJWT verifyJWTToken(String jwtToken, String baseUrl) throws Exception { + Algorithm algorithm = Algorithm.ECDSA256(keyProvider); + + // Time constraints are automatically checked, use acceptLeeway to specify a leeway window + // The token was issued in a past date "iat" < TODAY + // The token hasn't expired yet "exp" > TODAY + JWTVerifier verifier = + JWT.require(algorithm).withAudience(baseUrl).withIssuer(IAP_ISSUER_URL).build(); + + DecodedJWT decodedJWT = verifier.verify(jwtToken); + + if (decodedJWT.getSubject() == null) { + throw new JWTVerificationException("Subject expected, not found"); + } + if (decodedJWT.getClaim("email") == null) { + throw new JWTVerificationException("Email expected, not found"); + } + return decodedJWT; + } + + private ECPublicKey getKey(String keyText) throws IOException { + StringReader reader = new StringReader(keyText); + Section section = PemReader.readFirstSectionAndClose(reader, "PUBLIC KEY"); + if (section == null) { + throw new IOException("Invalid data."); + } else { + byte[] bytes = section.getBase64DecodedBytes(); + X509EncodedKeySpec keySpec = new X509EncodedKeySpec(bytes); + try { + KeyFactory kf = KeyFactory.getInstance("EC"); + PublicKey publicKey = kf.generatePublic(keySpec); + if (publicKey instanceof ECPublicKey) { + return (ECPublicKey) publicKey; + } + } catch (InvalidKeySpecException | NoSuchAlgorithmException var7) { + throw new IOException("Unexpected exception reading data", var7); + } + } + return null; + } + // [END verify_iap_request] +} diff --git a/iap/src/test/java/com/example/iap/BuildAndVerifyIapRequestIT.java b/iap/src/test/java/com/example/iap/BuildAndVerifyIapRequestIT.java new file mode 100644 index 00000000000..3638599a5dc --- /dev/null +++ b/iap/src/test/java/com/example/iap/BuildAndVerifyIapRequestIT.java @@ -0,0 +1,76 @@ +/** + * Copyright 2017 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.iap; + +import static com.example.iap.BuildIapRequest.buildIAPRequest; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; + +import com.auth0.jwt.interfaces.DecodedJWT; +import com.google.api.client.http.GenericUrl; +import com.google.api.client.http.HttpRequest; +import com.google.api.client.http.HttpResponse; +import com.google.api.client.http.HttpResponseException; +import com.google.api.client.http.HttpTransport; +import com.google.api.client.http.javanet.NetHttpTransport; +import org.apache.http.HttpStatus; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; + +@RunWith(JUnit4.class) +public class BuildAndVerifyIapRequestIT { + + private String iapProtectedUrl = System.getenv("IAP_PROTECTED_URL"); + private HttpTransport httpTransport = new NetHttpTransport(); + private VerifyIapRequestHeader verifyIapRequestHeader = new VerifyIapRequestHeader(); + + @Before + public void setUp() { + assertNotNull(iapProtectedUrl); + } + + // Access an IAP protected url without signed jwt authorization header + @Test + public void accessIapProtectedResourceFailsWithoutJwtHeader() throws Exception { + HttpRequest request = + httpTransport.createRequestFactory().buildGetRequest(new GenericUrl(iapProtectedUrl)); + try { + request.execute(); + } catch (HttpResponseException e) { + assertEquals(e.getStatusCode(), HttpStatus.SC_UNAUTHORIZED); + } + } + + // Access an IAP protected url with a signed jwt authorization header, verify jwt token + @Test + public void testGenerateAndVerifyIapRequestIsSuccessful() throws Exception { + HttpRequest request = + httpTransport.createRequestFactory().buildGetRequest(new GenericUrl(iapProtectedUrl)); + HttpRequest iapRequest = buildIAPRequest(request); + HttpResponse response = iapRequest.execute(); + assertEquals(response.getStatusCode(), HttpStatus.SC_OK); + String headerWithtoken = response.parseAsString(); + String[] split = headerWithtoken.split(":"); + assertNotNull(split); + assertEquals(split.length, 2); + assertEquals(split[0].trim(), "x-goog-authenticated-user-jwt"); + DecodedJWT decodedJWT = verifyIapRequestHeader.verifyJWTToken(split[1].trim(), iapProtectedUrl); + assertNotNull(decodedJWT); + } +}