diff --git a/oauth2_http/java/com/google/auth/oauth2/TokenVerifier.java b/oauth2_http/java/com/google/auth/oauth2/TokenVerifier.java new file mode 100644 index 000000000..bd2fc3e83 --- /dev/null +++ b/oauth2_http/java/com/google/auth/oauth2/TokenVerifier.java @@ -0,0 +1,362 @@ +/* + * Copyright 2020 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 + * + * https://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.google.auth.oauth2; + +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.HttpTransport; +import com.google.api.client.json.GenericJson; +import com.google.api.client.json.webtoken.JsonWebSignature; +import com.google.api.client.util.Base64; +import com.google.api.client.util.Clock; +import com.google.api.client.util.Key; +import com.google.auth.http.HttpTransportFactory; +import com.google.auto.value.AutoValue; +import com.google.common.base.Preconditions; +import com.google.common.cache.CacheBuilder; +import com.google.common.cache.CacheLoader; +import com.google.common.cache.LoadingCache; +import com.google.common.collect.ImmutableMap; +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.UnsupportedEncodingException; +import java.math.BigInteger; +import java.security.AlgorithmParameters; +import java.security.GeneralSecurityException; +import java.security.InvalidKeyException; +import java.security.KeyFactory; +import java.security.NoSuchAlgorithmException; +import java.security.PublicKey; +import java.security.Signature; +import java.security.SignatureException; +import java.security.cert.CertificateException; +import java.security.cert.CertificateFactory; +import java.security.spec.ECGenParameterSpec; +import java.security.spec.ECParameterSpec; +import java.security.spec.ECPoint; +import java.security.spec.ECPublicKeySpec; +import java.security.spec.InvalidKeySpecException; +import java.security.spec.InvalidParameterSpecException; +import java.security.spec.RSAPublicKeySpec; +import java.util.Arrays; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeUnit; +import javax.annotation.Nullable; + +public class TokenVerifier { + private static final String IAP_CERT_URL = "https://www.gstatic.com/iap/verify/public_key-jwk"; + private static final String FEDERATED_SIGNON_CERT_URL = + "https://www.googleapis.com/oauth2/v3/certs"; + + public static class JsonWebKeySet extends GenericJson { + @Key public List keys; + } + + public static class JsonWebKey { + @Key public String alg; + + @Key public String crv; + + @Key public String kid; + + @Key public String kty; + + @Key public String use; + + @Key public String x; + + @Key public String y; + + @Key public String e; + + @Key public String n; + } + + private static final LoadingCache> PUBLIC_KEY_CACHE = + CacheBuilder.newBuilder() + .expireAfterWrite(1, TimeUnit.HOURS) + .build( + new CacheLoader>() { + @Override + public Map load(String certificateUrl) throws Exception { + HttpTransportFactory httpTransportFactory = OAuth2Utils.HTTP_TRANSPORT_FACTORY; + HttpTransport httpTransport = httpTransportFactory.create(); + JsonWebKeySet jwks; + try { + HttpRequest request = + httpTransport + .createRequestFactory() + .buildGetRequest(new GenericUrl(certificateUrl)) + .setParser(OAuth2Utils.JSON_FACTORY.createJsonObjectParser()); + HttpResponse response = request.execute(); + jwks = response.parseAs(JsonWebKeySet.class); + } catch (IOException io) { + return ImmutableMap.of(); + } + + ImmutableMap.Builder keyCacheBuilder = + new ImmutableMap.Builder<>(); + if (jwks.keys == null) { + // Fall back to x509 formatted specification + for (String keyId : jwks.keySet()) { + String publicKeyPem = (String) jwks.get(keyId); + keyCacheBuilder.put(keyId, buildPublicKey(publicKeyPem)); + } + } else { + for (JsonWebKey key : jwks.keys) { + try { + keyCacheBuilder.put(key.kid, buildPublicKey(key)); + } catch (NoSuchAlgorithmException + | InvalidKeySpecException + | InvalidParameterSpecException ignored) { + ignored.printStackTrace(); + } + } + } + + return keyCacheBuilder.build(); + } + + private PublicKey buildPublicKey(JsonWebKey key) + throws NoSuchAlgorithmException, InvalidParameterSpecException, + InvalidKeySpecException { + if ("ES256".equals(key.alg)) { + return buildEs256PublicKey(key); + } else if ("RS256".equals((key.alg))) { + return buildRs256PublicKey(key); + } else { + return null; + } + } + + private PublicKey buildPublicKey(String publicPem) + throws CertificateException, UnsupportedEncodingException { + return CertificateFactory.getInstance("X.509") + .generateCertificate(new ByteArrayInputStream(publicPem.getBytes("UTF-8"))) + .getPublicKey(); + } + + private PublicKey buildRs256PublicKey(JsonWebKey key) + throws NoSuchAlgorithmException, InvalidKeySpecException { + Preconditions.checkArgument("RSA".equals(key.kty)); + Preconditions.checkNotNull(key.e); + Preconditions.checkNotNull(key.n); + + BigInteger modulus = new BigInteger(1, Base64.decodeBase64(key.n)); + BigInteger exponent = new BigInteger(1, Base64.decodeBase64(key.e)); + + RSAPublicKeySpec spec = new RSAPublicKeySpec(modulus, exponent); + KeyFactory factory = KeyFactory.getInstance("RSA"); + return factory.generatePublic(spec); + } + + private PublicKey buildEs256PublicKey(JsonWebKey key) + throws NoSuchAlgorithmException, InvalidParameterSpecException, + InvalidKeySpecException { + Preconditions.checkArgument("EC".equals(key.kty)); + Preconditions.checkArgument("P-256".equals(key.crv)); + + BigInteger x = new BigInteger(1, Base64.decodeBase64(key.x)); + BigInteger y = new BigInteger(1, Base64.decodeBase64(key.y)); + ECPoint pubPoint = new ECPoint(x, y); + AlgorithmParameters parameters = AlgorithmParameters.getInstance("EC"); + parameters.init(new ECGenParameterSpec("secp256r1")); + ECParameterSpec ecParameters = parameters.getParameterSpec(ECParameterSpec.class); + ECPublicKeySpec pubSpec = new ECPublicKeySpec(pubPoint, ecParameters); + KeyFactory kf = KeyFactory.getInstance("EC"); + return kf.generatePublic(pubSpec); + } + }); + + @AutoValue + public abstract static class VerifyOptions { + @Nullable + abstract String getAudience(); + + @Nullable + abstract String getIssuer(); + + @Nullable + abstract String getCertificatesLocation(); + + @Nullable + abstract PublicKey getPublicKey(); + + abstract Clock getClock(); + + static Builder newBuilder() { + return new AutoValue_TokenVerifier_VerifyOptions.Builder().setClock(Clock.SYSTEM); + } + + @AutoValue.Builder + abstract static class Builder { + abstract Builder setAudience(String audience); + + abstract Builder setCertificatesLocation(String certificatesLocation); + + abstract Builder setIssuer(String issuer); + + abstract Builder setPublicKey(PublicKey publicKey); + + abstract Builder setClock(Clock clock); + + abstract VerifyOptions build(); + } + } + + public static class VerificationException extends Exception { + public VerificationException(String message) { + super(message); + } + + public VerificationException(String message, Throwable cause) { + super(message, cause); + } + } + + public static boolean verify(String token, VerifyOptions verifyOptions) + throws VerificationException { + JsonWebSignature jsonWebSignature; + try { + jsonWebSignature = JsonWebSignature.parse(OAuth2Utils.JSON_FACTORY, token); + } catch (IOException e) { + throw new VerificationException("Error parsing JsonWebSignature token", e); + } + + // Verify the expected audience if an audience is provided in the verifyOptions + if (verifyOptions.getAudience() != null + && !verifyOptions.getAudience().equals(jsonWebSignature.getPayload().getAudience())) { + throw new VerificationException("Expected audience does not match"); + } + + // Verify the expected issuer if an issuer is provided in the verifyOptions + if (verifyOptions.getIssuer() != null + && !verifyOptions.getIssuer().equals(jsonWebSignature.getPayload().getIssuer())) { + throw new VerificationException("Expected issuer does not match"); + } + + Long expiresAt = jsonWebSignature.getPayload().getExpirationTimeSeconds(); + if (expiresAt != null && expiresAt <= verifyOptions.getClock().currentTimeMillis() / 1000) { + throw new VerificationException("Token is expired"); + } + + switch (jsonWebSignature.getHeader().getAlgorithm()) { + case "RS256": + return verifyRs256(jsonWebSignature, verifyOptions); + case "ES256": + return verifyEs256(jsonWebSignature, verifyOptions); + default: + throw new VerificationException( + "Unexpected signing algorithm: expected either RS256 or ES256"); + } + } + + private static boolean verifyEs256(JsonWebSignature jsonWebSignature, VerifyOptions verifyOptions) + throws VerificationException { + String certsUrl = + verifyOptions.getCertificatesLocation() == null + ? IAP_CERT_URL + : verifyOptions.getCertificatesLocation(); + PublicKey publicKey = verifyOptions.getPublicKey(); + if (publicKey == null) { + try { + publicKey = PUBLIC_KEY_CACHE.get(certsUrl).get(jsonWebSignature.getHeader().getKeyId()); + } catch (ExecutionException e) { + throw new VerificationException("Error fetching PublicKey for ES256 token", e); + } + } + try { + Signature signatureAlgorithm = Signature.getInstance("SHA256withECDSA"); + signatureAlgorithm.initVerify(publicKey); + signatureAlgorithm.update(jsonWebSignature.getSignedContentBytes()); + byte[] derBytes = convertDerBytes(jsonWebSignature.getSignatureBytes()); + return signatureAlgorithm.verify(derBytes); + } catch (NoSuchAlgorithmException | InvalidKeyException | SignatureException e) { + throw new VerificationException("Error validating ES256 token", e); + } + } + + private static boolean verifyRs256(JsonWebSignature jsonWebSignature, VerifyOptions verifyOptions) + throws VerificationException { + String certsUrl = + verifyOptions.getCertificatesLocation() == null + ? FEDERATED_SIGNON_CERT_URL + : verifyOptions.getCertificatesLocation(); + PublicKey publicKey = verifyOptions.getPublicKey(); + if (publicKey == null) { + try { + publicKey = PUBLIC_KEY_CACHE.get(certsUrl).get(jsonWebSignature.getHeader().getKeyId()); + } catch (ExecutionException e) { + throw new VerificationException("Error fetching PublicKey for ES256 token", e); + } + } + if (publicKey == null) { + throw new VerificationException( + "Could not find publicKey for provided keyId: " + + jsonWebSignature.getHeader().getKeyId()); + } + try { + return jsonWebSignature.verifySignature(publicKey); + } catch (GeneralSecurityException e) { + throw new VerificationException("Error validating RS256 token", e); + } + } + + /** + * Verify a Json Web Signature token against Google's published public keys. + * + * @param token The JWS token expressed as a string + * @return true if we can verify the provided token against Google's tokens + * @throws VerificationException if the provided token string cannot be parsed as a valid + * JsonWebSignature + */ + public static boolean verify(String token) throws VerificationException { + return verify(token, VerifyOptions.newBuilder().build()); + } + + private static byte DER_TAG_SIGNATURE_OBJECT = 0x30; + private static byte DER_TAG_ASN1_INTEGER = 0x02; + + private static byte[] convertDerBytes(byte[] signature) { + // expect the signature to be 64 bytes long + Preconditions.checkState(signature.length == 64); + + byte[] int1 = new BigInteger(1, Arrays.copyOfRange(signature, 0, 32)).toByteArray(); + byte[] int2 = new BigInteger(1, Arrays.copyOfRange(signature, 32, 64)).toByteArray(); + byte[] der = new byte[6 + int1.length + int2.length]; + + // Mark that this is a signature object + der[0] = DER_TAG_SIGNATURE_OBJECT; + der[1] = (byte) (der.length - 2); + + // Start ASN1 integer and write the first 32 bits + der[2] = DER_TAG_ASN1_INTEGER; + der[3] = (byte) int1.length; + System.arraycopy(int1, 0, der, 4, int1.length); + + // Start ASN1 integer and write the second 32 bits + int offset = int1.length + 4; + der[offset] = DER_TAG_ASN1_INTEGER; + der[offset + 1] = (byte) int2.length; + System.arraycopy(int2, 0, der, offset + 2, int2.length); + + return der; + } +} diff --git a/oauth2_http/javatests/com/google/auth/TestUtils.java b/oauth2_http/javatests/com/google/auth/TestUtils.java index 0726fb1f6..81fa9057f 100644 --- a/oauth2_http/javatests/com/google/auth/TestUtils.java +++ b/oauth2_http/javatests/com/google/auth/TestUtils.java @@ -40,10 +40,7 @@ import com.google.auth.http.AuthHttpConstants; import com.google.common.base.Splitter; import com.google.common.collect.Lists; -import java.io.ByteArrayInputStream; -import java.io.IOException; -import java.io.InputStream; -import java.io.UnsupportedEncodingException; +import java.io.*; import java.net.URLDecoder; import java.util.HashMap; import java.util.List; @@ -81,6 +78,18 @@ private static boolean hasBearerToken(Map> metadata, String return false; } + // public foo() { + // // for ServiceAccountCredentials + // ServiceAccountCredentials saCreds = ServiceAccountCredentials.fromStream(new + // FileInputStream(credPath)); + // saCreds = (ServiceAccountCredentials) + // saCreds.createScoped(Arrays.asList("https://www.googleapis.com/auth/iam")); + // IdTokenCredentials tokenCredential = IdTokenCredentials.newBuilder() + // .setIdTokenProvider(saCreds) + // .setTargetAudience(targetAudience).build(); + // Http + // } + public static InputStream jsonToInputStream(GenericJson json) throws IOException { json.setFactory(JSON_FACTORY); String text = json.toPrettyString(); diff --git a/oauth2_http/javatests/com/google/auth/oauth2/TokenVerifierTest.java b/oauth2_http/javatests/com/google/auth/oauth2/TokenVerifierTest.java new file mode 100644 index 000000000..77cd0774a --- /dev/null +++ b/oauth2_http/javatests/com/google/auth/oauth2/TokenVerifierTest.java @@ -0,0 +1,129 @@ +/* + * Copyright 2020 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 + * + * https://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.google.auth.oauth2; + +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; + +import java.util.Arrays; +import java.util.List; + +import com.google.api.client.util.Clock; +import org.junit.Test; + +public class TokenVerifierTest { + private static final String ES256_TOKEN = + "eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6Im1wZjBEQSJ9.eyJhdWQiOiIvcHJvamVjdHMvNjUyNTYyNzc2Nzk4L2FwcHMvY2xvdWQtc2FtcGxlcy10ZXN0cy1waHAtaWFwIiwiZW1haWwiOiJjaGluZ29yQGdvb2dsZS5jb20iLCJleHAiOjE1ODQwNDc2MTcsImdvb2dsZSI6eyJhY2Nlc3NfbGV2ZWxzIjpbImFjY2Vzc1BvbGljaWVzLzUxODU1MTI4MDkyNC9hY2Nlc3NMZXZlbHMvcmVjZW50U2VjdXJlQ29ubmVjdERhdGEiLCJhY2Nlc3NQb2xpY2llcy81MTg1NTEyODA5MjQvYWNjZXNzTGV2ZWxzL3Rlc3ROb09wIiwiYWNjZXNzUG9saWNpZXMvNTE4NTUxMjgwOTI0L2FjY2Vzc0xldmVscy9ldmFwb3JhdGlvblFhRGF0YUZ1bGx5VHJ1c3RlZCJdfSwiaGQiOiJnb29nbGUuY29tIiwiaWF0IjoxNTg0MDQ3MDE3LCJpc3MiOiJodHRwczovL2Nsb3VkLmdvb2dsZS5jb20vaWFwIiwic3ViIjoiYWNjb3VudHMuZ29vZ2xlLmNvbToxMTIxODE3MTI3NzEyMDE5NzI4OTEifQ.yKNtdFY5EKkRboYNexBdfugzLhC3VuGyFcuFYA8kgpxMqfyxa41zkML68hYKrWu2kOBTUW95UnbGpsIi_u1fiA"; + + private static final String FEDERATED_SIGNON_RS256_TOKEN = + "eyJhbGciOiJSUzI1NiIsImtpZCI6ImY5ZDk3YjRjYWU5MGJjZDc2YWViMjAwMjZmNmI3NzBjYWMyMjE3ODMiLCJ0eXAiOiJKV1QifQ.eyJhdWQiOiJodHRwczovL2V4YW1wbGUuY29tL3BhdGgiLCJhenAiOiJpbnRlZ3JhdGlvbi10ZXN0c0BjaGluZ29yLXRlc3QuaWFtLmdzZXJ2aWNlYWNjb3VudC5jb20iLCJlbWFpbCI6ImludGVncmF0aW9uLXRlc3RzQGNoaW5nb3ItdGVzdC5pYW0uZ3NlcnZpY2VhY2NvdW50LmNvbSIsImVtYWlsX3ZlcmlmaWVkIjp0cnVlLCJleHAiOjE1ODc2Mjk4ODgsImlhdCI6MTU4NzYyNjI4OCwiaXNzIjoiaHR0cHM6Ly9hY2NvdW50cy5nb29nbGUuY29tIiwic3ViIjoiMTA0MDI5MjkyODUzMDk5OTc4MjkzIn0.Pj4KsJh7riU7ZIbPMcHcHWhasWEcbVjGP4yx_5E0iOpeDalTdri97E-o0dSSkuVX2FeBIgGUg_TNNgJ3YY97T737jT5DUYwdv6M51dDlLmmNqlu_P6toGCSRC8-Beu5gGmqS2Y82TmpHH9Vhoh5PsK7_rVHk8U6VrrVVKKTWm_IzTFhqX1oYKPdvfyaNLsXPbCt_NFE0C3DNmFkgVhRJu7LtzQQN-ghaqd3Ga3i6KH222OEI_PU4BUTvEiNOqRGoMlT_YOsyFN3XwqQ6jQGWhhkArL1z3CG2BVQjHTKpgVsRyy_H6WTZiju2Q-XWobgH-UPSZbyymV8-cFT9XKEtZQ"; + private static final String LEGACY_FEDERATED_SIGNON_CERT_URL = + "https://www.googleapis.com/oauth2/v1/certs"; + + private static final String SERVICE_ACCOUNT_RS256_TOKEN = + "eyJhbGciOiJSUzI1NiIsImtpZCI6IjJlZjc3YjM4YTFiMDM3MDQ4NzA0MzkxNmFjYmYyN2Q3NGVkZDA4YjEiLCJ0eXAiOiJKV1QifQ.eyJhdWQiOiJodHRwczovL2V4YW1wbGUuY29tL2F1ZGllbmNlIiwiZXhwIjoxNTg3NjMwNTQzLCJpYXQiOjE1ODc2MjY5NDMsImlzcyI6InNvbWUgaXNzdWVyIiwic3ViIjoic29tZSBzdWJqZWN0In0.gGOQW0qQgs4jGUmCsgRV83RqsJLaEy89-ZOG6p1u0Y26FyY06b6Odgd7xXLsSTiiSnch62dl0Lfi9D0x2ByxvsGOCbovmBl2ZZ0zHr1wpc4N0XS9lMUq5RJQbonDibxXG4nC2zroDfvD0h7i-L8KMXeJb9pYwW7LkmrM_YwYfJnWnZ4bpcsDjojmPeUBlACg7tjjOgBFbyQZvUtaERJwSRlaWibvNjof7eCVfZChE0PwBpZc_cGqSqKXv544L4ttqdCnmONjqrTATXwC4gYxruevkjHfYI5ojcQmXoWDJJ0-_jzfyPE4MFFdCFgzLgnfIOwe5ve0MtquKuv2O0pgvg"; + private static final String SERVICE_ACCOUNT_CERT_URL = + "https://www.googleapis.com/robot/v1/metadata/x509/integration-tests%40chingor-test.iam.gserviceaccount.com"; + + private static final List ALL_TOKENS = + Arrays.asList(ES256_TOKEN, FEDERATED_SIGNON_RS256_TOKEN, SERVICE_ACCOUNT_RS256_TOKEN); + + // Fixed to 2020-02-26 08:00:00 to allow expiration tests to pass + private static final Clock FIXED_CLOCK = new Clock() { + @Override + public long currentTimeMillis() { + return 1582704000000L; + } + }; + + @Test + public void verifyExpiredToken() { + for (String token : ALL_TOKENS) { + try { + TokenVerifier.verify(token); + fail("Should have thrown a VerificationException"); + } catch (TokenVerifier.VerificationException e) { + assertTrue(e.getMessage().contains("expired")); + } + } + } + + @Test + public void verifyExpectedAudience() { + for (String token : ALL_TOKENS) { + try { + TokenVerifier.verify( + token, + TokenVerifier.VerifyOptions.newBuilder().setAudience("expected audience").build()); + fail("Should have thrown a VerificationException"); + } catch (TokenVerifier.VerificationException e) { + assertTrue(e.getMessage().contains("audience does not match")); + } + } + } + + @Test + public void verifyExpectedIssuer() { + for (String token : ALL_TOKENS) { + try { + TokenVerifier.verify( + token, TokenVerifier.VerifyOptions.newBuilder().setIssuer("expected issuer").build()); + fail("Should have thrown a VerificationException"); + } catch (TokenVerifier.VerificationException e) { + assertTrue(e.getMessage().contains("issuer does not match")); + } + } + } + + @Test + public void verifyEs256Token() throws TokenVerifier.VerificationException { + assertTrue( + TokenVerifier.verify( + ES256_TOKEN, + TokenVerifier.VerifyOptions.newBuilder().setClock(FIXED_CLOCK).build())); + } + + @Test + public void verifyRs256Token() throws TokenVerifier.VerificationException { + assertTrue( + TokenVerifier.verify( + FEDERATED_SIGNON_RS256_TOKEN, + TokenVerifier.VerifyOptions.newBuilder().setClock(FIXED_CLOCK).build())); + } + + @Test + public void verifyRs256TokenWithLegacyCertificateUrlFormat() + throws TokenVerifier.VerificationException { + assertTrue( + TokenVerifier.verify( + FEDERATED_SIGNON_RS256_TOKEN, + TokenVerifier.VerifyOptions.newBuilder() + .setCertificatesLocation(LEGACY_FEDERATED_SIGNON_CERT_URL) + .setClock(FIXED_CLOCK) + .build())); + } + + @Test + public void verifyServiceAccountRs256Token() throws TokenVerifier.VerificationException { + assertTrue( + TokenVerifier.verify( + SERVICE_ACCOUNT_RS256_TOKEN, + TokenVerifier.VerifyOptions.newBuilder() + .setCertificatesLocation(SERVICE_ACCOUNT_CERT_URL) + .setClock(FIXED_CLOCK) + .build())); + } +} diff --git a/pom.xml b/pom.xml index 9398b6a89..e6ec16109 100644 --- a/pom.xml +++ b/pom.xml @@ -421,5 +421,84 @@ + + + autovalue-java7 + + 1.7 + + + 1.7 + 1.4 + + + + + maven-compiler-plugin + + + + com.google.auto.value + auto-value + ${auto-value.version} + + + + + + + + + + + autovalue-java8 + + [1.8,) + + + 1.7 + ${auto-value.version} + 1.0-rc6 + + + + + maven-compiler-plugin + + + + com.google.auto.value + auto-value + ${auto-value.version} + + + + com.google.auto.service + auto-service-annotations + ${auto-service-annotations.version} + + + + + + +