From 40fe35ad652aa4c69ab03f3dc40e41c5de7e0de9 Mon Sep 17 00:00:00 2001 From: Stanislav Paltis Date: Tue, 30 Jun 2026 18:27:54 -0400 Subject: [PATCH 1/4] HPI-22073 IAM-10197 IAM-10215 Update here-aaa-java-sdk to support jwt assertions Signed-off-by: Stanislav Paltis --- .github/workflows/test.yml | 12 +- .gitignore | 7 + README.md | 43 ++ .../auth/JwtClientAssertionBuilder.java | 209 +++++++ .../auth/JwtClientAssertionProvider.java | 308 ++++++++++ .../FromHereCredentialsIniStream.java | 31 +- .../auth/provider/FromSystemProperties.java | 31 +- ...lientAssertionCredentialsGrantRequest.java | 85 +++ .../auth/JwtClientAssertionBuilderTest.java | 207 +++++++ .../auth/JwtClientAssertionProviderTest.java | 313 ++++++++++ ...tAssertionCredentialsGrantRequestTest.java | 92 +++ .../account/oauth2/JwtClientAssertionIT.java | 535 ++++++++++++++++++ .../here/account/oauth2/JwtTestSetupUtil.java | 185 ++++++ 13 files changed, 2055 insertions(+), 3 deletions(-) create mode 100644 here-oauth-client/src/main/java/com/here/account/auth/JwtClientAssertionBuilder.java create mode 100644 here-oauth-client/src/main/java/com/here/account/auth/JwtClientAssertionProvider.java create mode 100644 here-oauth-client/src/main/java/com/here/account/oauth2/ClientAssertionCredentialsGrantRequest.java create mode 100644 here-oauth-client/src/test/java/com/here/account/auth/JwtClientAssertionBuilderTest.java create mode 100644 here-oauth-client/src/test/java/com/here/account/auth/JwtClientAssertionProviderTest.java create mode 100644 here-oauth-client/src/test/java/com/here/account/oauth2/ClientAssertionCredentialsGrantRequestTest.java create mode 100644 here-oauth-client/src/test/java/com/here/account/oauth2/JwtClientAssertionIT.java create mode 100644 here-oauth-client/src/test/java/com/here/account/oauth2/JwtTestSetupUtil.java diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 65d84bf9..b1282071 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -23,4 +23,14 @@ jobs: - name: Verify Maven installation run: mvn -v - name: Run AAA SDK tests - run: mvn -Dhere.token.endpoint.url=https://stg.account.api.here.com/oauth2/token -Dhere.access.key.id=${{ secrets.ACCESS_KEY_ID }} -Dhere.access.key.secret=${{ secrets.ACCESS_KEY_SECRET }} clean install \ No newline at end of file + run: mvn -Dhere.token.endpoint.url=https://stg.account.api.here.com/oauth2/token -Dhere.access.key.id=${{ secrets.ACCESS_KEY_ID }} -Dhere.access.key.secret=${{ secrets.ACCESS_KEY_SECRET }} clean install + - name: Run JWT client assertion integration test + if: ${{ secrets.JWT_PRIVATE_KEY != '' }} + run: | + mvn test -pl here-oauth-client -Dtest=JwtClientAssertionIT \ + -Dhere.token.endpoint.url=https://stg.account.api.here.com/oauth2/token \ + -Dhere.jwt.client.id=${{ secrets.JWT_CLIENT_ID }} \ + -Dhere.jwt.private.key="${{ secrets.JWT_PRIVATE_KEY }}" \ + -Dhere.jwt.key.id=${{ secrets.JWT_KEY_ID }} \ + -Dhere.jwt.token.scope=${{ secrets.JWT_TOKEN_SCOPE }} + diff --git a/.gitignore b/.gitignore index c4f3d566..f0736001 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,10 @@ target /bin/ pom.xml.versionsBackup examples/here-oauth-client-example/dependency-reduced-pom.xml + +# Credentials / secrets — never commit +*.credentials.properties +credentials.properties +credentials.ini +*.pem +*.key diff --git a/README.md b/README.md index 6bce71f7..f9e0d3b7 100644 --- a/README.md +++ b/README.md @@ -192,6 +192,49 @@ You can also use one of the proxy options to getAccessToken If you want move advanced options, you can provide your own HttpProvider `HereAccessTokenProvider.builder().setHttpProvider().build();` +OAuth 2.1 private_key_jwt Authentication +---------------------------------------- + +A fourth option uses OAuth 2.1 JWT client assertions (`private_key_jwt` per RFC 7523 §2.2) instead of OAuth 1.0 signatures. +The client authenticates by signing a short-lived JWT with its RSA private key. The corresponding public key must be +registered on the HERE Account server as a JWK. + +Credentials file (`~/.here/credentials.properties` or `~/.here/credentials.ini`): +```properties +here.token.endpoint.url=https://account.api.here.com/oauth2/token +here.auth.method=private_key_jwt +here.client.id= +here.private.key=/path/to/private-key.pem +here.key.id= +``` + +Usage (same API as OAuth 1.0 — auto-detected by the provider chain): +```java +try (HereAccessTokenProvider accessTokens = HereAccessTokenProvider.builder().build()) { + String accessToken = accessTokens.getAccessToken(); +} +``` + +Or construct the provider explicitly: +```java +JwtClientAssertionProvider provider = new JwtClientAssertionProvider( + new SettableSystemClock(), + "https://account.api.here.com/oauth2/token", + "", + JwtClientAssertionProvider.loadPrivateKey("/path/to/private-key.pem"), + null, // scope (optional) + "" // key ID (optional) +); +try (HereAccessTokenProvider accessTokens = HereAccessTokenProvider.builder() + .setClientAuthorizationRequestProvider(provider) + .build()) { + String accessToken = accessTokens.getAccessToken(); +} +``` + +The private key must be a PKCS#8 PEM-encoded RSA key. The `here.private.key` value can be either +a file path or the inline PEM content itself (useful for environment variables in CI). + # License Copyright (C) 2016-2019 HERE Europe B.V. diff --git a/here-oauth-client/src/main/java/com/here/account/auth/JwtClientAssertionBuilder.java b/here-oauth-client/src/main/java/com/here/account/auth/JwtClientAssertionBuilder.java new file mode 100644 index 00000000..45e373c5 --- /dev/null +++ b/here-oauth-client/src/main/java/com/here/account/auth/JwtClientAssertionBuilder.java @@ -0,0 +1,209 @@ +/* + * Copyright (c) 2025 HERE Europe B.V. + * + * 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.here.account.auth; + +import com.here.account.util.Clock; +import com.here.account.util.OAuthConstants; + +import java.security.InvalidKeyException; +import java.security.NoSuchAlgorithmException; +import java.security.PrivateKey; +import java.security.Signature; +import java.security.SignatureException; +import java.util.Base64; +import java.util.Objects; +import java.util.UUID; + +/** + * Builds and signs JWT client assertions for use with OAuth 2.1 private_key_jwt + * authentication per RFC 7523 Section 2.2. + * + *

+ * The produced JWT has: + *

    + *
  • Header: {@code {"alg":"RS256","typ":"JWT"}}
  • + *
  • Claims: iss, sub (both = client_id), aud (token endpoint), exp, iat, jti
  • + *
  • Signature: RS256 (RSASSA-PKCS1-v1_5 with SHA-256)
  • + *
+ * + * @see RFC 7523 §2.2 + * @see RFC 7519 (JWT) + * @see OAuth 2.1 + */ +public class JwtClientAssertionBuilder { + + private static final String SIGNATURE_ALGORITHM = "SHA256withRSA"; + + /** + * Default token lifetime: 5 minutes (300 seconds). + * Per RFC 7523 §3, the assertion SHOULD have a short lifetime. + */ + private static final long DEFAULT_EXPIRY_SECONDS = 300L; + + private final String clientId; + private final String tokenEndpointUrl; + private final PrivateKey privateKey; + private final Clock clock; + private long expirySeconds = DEFAULT_EXPIRY_SECONDS; + private String kid; + + /** + * Construct a new JWT client assertion builder. + * + * @param clock the clock for timestamps + * @param clientId the client identifier (becomes iss and sub) + * @param tokenEndpointUrl the token endpoint URL (becomes aud) + * @param privateKey the RSA private key for signing + */ + public JwtClientAssertionBuilder(Clock clock, String clientId, String tokenEndpointUrl, PrivateKey privateKey) { + Objects.requireNonNull(clock, "clock is required"); + Objects.requireNonNull(clientId, "clientId is required"); + Objects.requireNonNull(tokenEndpointUrl, "tokenEndpointUrl is required"); + Objects.requireNonNull(privateKey, "privateKey is required"); + + this.clock = clock; + this.clientId = clientId; + this.tokenEndpointUrl = tokenEndpointUrl; + this.privateKey = privateKey; + } + + /** + * Set a custom expiry duration for the assertion JWT. + * + * @param expirySeconds lifetime in seconds (must be positive, max recommended 300s) + * @return this builder + */ + public JwtClientAssertionBuilder setExpirySeconds(long expirySeconds) { + if (expirySeconds <= 0) { + throw new IllegalArgumentException("expirySeconds must be positive"); + } + this.expirySeconds = expirySeconds; + return this; + } + + /** + * Set the Key ID (kid) to include in the JWT header. + * When the application has multiple registered JWKs, the kid helps the + * authorization server identify which public key to use for verification. + * + * @param kid the key identifier (matches the kid registered on the app's JWK) + * @return this builder + */ + public JwtClientAssertionBuilder setKid(String kid) { + this.kid = kid; + return this; + } + + /** + * Build and sign a new JWT client assertion. + * + *

Per RFC 7523 §3, the JWT MUST contain: + *

    + *
  • iss - the client_id
  • + *
  • sub - the client_id
  • + *
  • aud - the token endpoint URL
  • + *
  • exp - expiration time
  • + *
  • iat - issued at time
  • + *
  • jti - unique token identifier (prevents replay)
  • + *
+ * + * @return the compact serialized JWT (header.payload.signature) + * @throws ClientAssertionException if signing fails + */ + public String buildAssertion() { + long nowSeconds = clock.currentTimeMillis() / 1000L; + long exp = nowSeconds + expirySeconds; + String jti = UUID.randomUUID().toString(); + + String header = buildHeaderJson(); + String payload = buildPayloadJson(nowSeconds, exp, jti); + + String headerEncoded = base64UrlEncode(header.getBytes(OAuthConstants.UTF_8_CHARSET)); + String payloadEncoded = base64UrlEncode(payload.getBytes(OAuthConstants.UTF_8_CHARSET)); + + String signingInput = headerEncoded + "." + payloadEncoded; + String signature = sign(signingInput); + + return signingInput + "." + signature; + } + + private String buildHeaderJson() { + if (kid != null && !kid.isEmpty()) { + return "{\"alg\":\"RS256\",\"typ\":\"JWT\",\"kid\":\"" + escapeJson(kid) + "\"}"; + } + return "{\"alg\":\"RS256\",\"typ\":\"JWT\"}"; + } + + private String buildPayloadJson(long iat, long exp, String jti) { + // Manual JSON construction to avoid dependency on serialization library for this simple case. + // All string values are safe (no special chars that need escaping in client_id, URL, UUID). + return "{" + + "\"iss\":\"" + escapeJson(clientId) + "\"," + + "\"sub\":\"" + escapeJson(clientId) + "\"," + + "\"aud\":\"" + escapeJson(tokenEndpointUrl) + "\"," + + "\"exp\":" + exp + "," + + "\"iat\":" + iat + "," + + "\"jti\":\"" + escapeJson(jti) + "\"" + + "}"; + } + + private String sign(String signingInput) { + try { + Signature sig = Signature.getInstance(SIGNATURE_ALGORITHM); + sig.initSign(privateKey); + sig.update(signingInput.getBytes(OAuthConstants.UTF_8_CHARSET)); + byte[] signatureBytes = sig.sign(); + return base64UrlEncode(signatureBytes); + } catch (NoSuchAlgorithmException | InvalidKeyException | SignatureException e) { + throw new ClientAssertionException("Failed to sign JWT client assertion: " + e.getMessage(), e); + } + } + + private static String base64UrlEncode(byte[] data) { + return Base64.getUrlEncoder().withoutPadding().encodeToString(data); + } + + /** + * Minimal JSON string escaping for known-safe values (client IDs, URLs, UUIDs). + */ + private static String escapeJson(String value) { + if (value == null) { + return ""; + } + StringBuilder sb = new StringBuilder(value.length()); + for (int i = 0; i < value.length(); i++) { + char c = value.charAt(i); + switch (c) { + case '"': sb.append("\\\""); break; + case '\\': sb.append("\\\\"); break; + case '\n': sb.append("\\n"); break; + case '\r': sb.append("\\r"); break; + case '\t': sb.append("\\t"); break; + default: sb.append(c); + } + } + return sb.toString(); + } + + /** + * Exception thrown when JWT client assertion building/signing fails. + */ + public static class ClientAssertionException extends RuntimeException { + public ClientAssertionException(String message, Throwable cause) { + super(message, cause); + } + } +} diff --git a/here-oauth-client/src/main/java/com/here/account/auth/JwtClientAssertionProvider.java b/here-oauth-client/src/main/java/com/here/account/auth/JwtClientAssertionProvider.java new file mode 100644 index 00000000..c4c58b82 --- /dev/null +++ b/here-oauth-client/src/main/java/com/here/account/auth/JwtClientAssertionProvider.java @@ -0,0 +1,308 @@ +/* + * Copyright (c) 2025 HERE Europe B.V. + * + * 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.here.account.auth; + +import com.here.account.http.HttpConstants.HttpMethods; +import com.here.account.http.HttpProvider; +import com.here.account.oauth2.AccessTokenRequest; +import com.here.account.oauth2.ClientAssertionCredentialsGrantRequest; +import com.here.account.oauth2.ClientAuthorizationRequestProvider; +import com.here.account.util.Clock; +import com.here.account.util.OAuthConstants; +import com.here.account.util.SettableSystemClock; + +import java.io.BufferedReader; +import java.io.File; +import java.io.FileInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.security.KeyFactory; +import java.security.NoSuchAlgorithmException; +import java.security.PrivateKey; +import java.security.spec.InvalidKeySpecException; +import java.security.spec.PKCS8EncodedKeySpec; +import java.util.Base64; +import java.util.Objects; +import java.util.Properties; + +/** + * A {@link ClientAuthorizationRequestProvider} that authenticates token requests + * using JWT client assertions (private_key_jwt) per OAuth 2.1 and RFC 7523 §2.2. + * + *

+ * This provider: + *

    + *
  • Uses {@link NoAuthorizer} — no Authorization header is sent (authentication is in the body)
  • + *
  • Generates a fresh signed JWT assertion for each token request via {@link JwtClientAssertionBuilder}
  • + *
  • Supports RS256 signing algorithm
  • + *
+ * + *

+ * Credentials properties: + *

    + *
  • {@value #TOKEN_ENDPOINT_URL_PROPERTY} - the token endpoint URL
  • + *
  • {@value #CLIENT_ID_PROPERTY} - the client identifier
  • + *
  • {@value #PRIVATE_KEY_PROPERTY} - PEM-encoded PKCS#8 RSA private key (inline or file path)
  • + *
  • {@value #TOKEN_SCOPE_PROPERTY} - (optional) token scope
  • + *
+ * + * @see RFC 7523 §2.2 + * @see OAuth 2.1 + */ +public class JwtClientAssertionProvider implements ClientAuthorizationRequestProvider { + + public static final String TOKEN_ENDPOINT_URL_PROPERTY = "here.token.endpoint.url"; + public static final String CLIENT_ID_PROPERTY = "here.client.id"; + public static final String PRIVATE_KEY_PROPERTY = "here.private.key"; + public static final String TOKEN_SCOPE_PROPERTY = "here.token.scope"; + public static final String AUTH_METHOD_PROPERTY = "here.auth.method"; + public static final String KEY_ID_PROPERTY = "here.key.id"; + + /** + * The auth method value that identifies private_key_jwt authentication. + */ + public static final String AUTH_METHOD_PRIVATE_KEY_JWT = "private_key_jwt"; + + private final Clock clock; + private final String tokenEndpointUrl; + private final String clientId; + private final PrivateKey privateKey; + private final String scope; + private final String kid; + private final NoAuthorizer noAuthorizer = new NoAuthorizer(); + + /** + * Construct a new JwtClientAssertionProvider. + * + * @param clock the clock implementation + * @param tokenEndpointUrl the token endpoint URL + * @param clientId the client identifier + * @param privateKey the RSA private key for signing assertions + * @param scope the optional token scope (may be null) + */ + public JwtClientAssertionProvider(Clock clock, String tokenEndpointUrl, String clientId, + PrivateKey privateKey, String scope) { + this(clock, tokenEndpointUrl, clientId, privateKey, scope, null); + } + + /** + * Construct a new JwtClientAssertionProvider with key ID. + * + * @param clock the clock implementation + * @param tokenEndpointUrl the token endpoint URL + * @param clientId the client identifier + * @param privateKey the RSA private key for signing assertions + * @param scope the optional token scope (may be null) + * @param kid the optional key ID to include in JWT header (may be null) + */ + public JwtClientAssertionProvider(Clock clock, String tokenEndpointUrl, String clientId, + PrivateKey privateKey, String scope, String kid) { + Objects.requireNonNull(clock, "clock is required"); + Objects.requireNonNull(tokenEndpointUrl, "tokenEndpointUrl is required"); + Objects.requireNonNull(clientId, "clientId is required"); + Objects.requireNonNull(privateKey, "privateKey is required"); + + this.clock = clock; + this.tokenEndpointUrl = tokenEndpointUrl; + this.clientId = clientId; + this.privateKey = privateKey; + this.scope = scope; + this.kid = kid; + } + + /** + * Construct from properties. Convenience constructor. + * + * @param clock the clock implementation + * @param properties properties containing token endpoint, client id, and private key + */ + public JwtClientAssertionProvider(Clock clock, Properties properties) { + this(clock, + properties.getProperty(TOKEN_ENDPOINT_URL_PROPERTY), + properties.getProperty(CLIENT_ID_PROPERTY), + loadPrivateKey(properties.getProperty(PRIVATE_KEY_PROPERTY)), + properties.getProperty(TOKEN_SCOPE_PROPERTY), + properties.getProperty(KEY_ID_PROPERTY)); + } + + /** + * {@inheritDoc} + */ + @Override + public String getTokenEndpointUrl() { + return tokenEndpointUrl; + } + + /** + * Returns a {@link NoAuthorizer} since private_key_jwt authentication + * is conveyed in the request body (client_assertion), not in the + * Authorization header. + * + * {@inheritDoc} + */ + @Override + public HttpProvider.HttpRequestAuthorizer getClientAuthorizer() { + return noAuthorizer; + } + + /** + * Builds a new {@link ClientAssertionCredentialsGrantRequest} with a freshly + * signed JWT assertion. + * + * {@inheritDoc} + */ + @Override + public AccessTokenRequest getNewAccessTokenRequest() { + JwtClientAssertionBuilder builder = new JwtClientAssertionBuilder( + clock, clientId, tokenEndpointUrl, privateKey); + if (kid != null && !kid.isEmpty()) { + builder.setKid(kid); + } + String assertion = builder.buildAssertion(); + + AccessTokenRequest request = new ClientAssertionCredentialsGrantRequest(assertion); + request.setScope(scope); + return request; + } + + /** + * {@inheritDoc} + */ + @Override + public HttpMethods getHttpMethod() { + return HttpMethods.POST; + } + + /** + * {@inheritDoc} + */ + @Override + public Clock getClock() { + return clock; + } + + /** + * {@inheritDoc} + */ + @Override + public String getScope() { + return scope; + } + + /** + * Load an RSA private key from PEM-encoded PKCS#8 content. + * The value can be either: + *
    + *
  • An inline PEM string (containing BEGIN/END markers or raw base64)
  • + *
  • A file path to a PEM file
  • + *
+ * + * @param keyData the PEM string or file path + * @return the RSA PrivateKey + * @throws IllegalArgumentException if the key cannot be loaded or parsed + */ + public static PrivateKey loadPrivateKey(String keyData) { + Objects.requireNonNull(keyData, "private key data is required"); + + String pemContent; + if (keyData.contains("-----BEGIN") || keyData.contains("MII")) { + // Inline PEM or raw base64 + pemContent = keyData; + } else { + // Treat as file path + pemContent = readFileContent(keyData); + } + + return parsePrivateKeyPem(pemContent); + } + + /** + * Parse a PEM-encoded PKCS#8 RSA private key. + * + * @param pem the PEM content + * @return the PrivateKey + */ + static PrivateKey parsePrivateKeyPem(String pem) { + String base64 = pem + .replace("-----BEGIN PRIVATE KEY-----", "") + .replace("-----END PRIVATE KEY-----", "") + .replace("-----BEGIN RSA PRIVATE KEY-----", "") + .replace("-----END RSA PRIVATE KEY-----", "") + .replaceAll("\\s+", ""); + + try { + byte[] keyBytes = Base64.getDecoder().decode(base64); + PKCS8EncodedKeySpec keySpec = new PKCS8EncodedKeySpec(keyBytes); + KeyFactory keyFactory = KeyFactory.getInstance("RSA"); + return keyFactory.generatePrivate(keySpec); + } catch (NoSuchAlgorithmException | InvalidKeySpecException | IllegalArgumentException e) { + throw new IllegalArgumentException("Failed to parse RSA private key: " + e.getMessage(), e); + } + } + + private static String readFileContent(String filePath) { + File file = new File(filePath); + if (!file.exists() || !file.isFile()) { + throw new IllegalArgumentException("Private key file not found: " + filePath); + } + try (InputStream is = new FileInputStream(file); + BufferedReader reader = new BufferedReader(new InputStreamReader(is, OAuthConstants.UTF_8_CHARSET))) { + StringBuilder sb = new StringBuilder(); + String line; + while ((line = reader.readLine()) != null) { + sb.append(line).append('\n'); + } + return sb.toString(); + } catch (IOException e) { + throw new IllegalArgumentException("Failed to read private key file: " + filePath, e); + } + } + + /** + * Check if the given properties indicate private_key_jwt authentication. + * + * @param properties the properties to check + * @return true if auth method is private_key_jwt and required fields are present + */ + public static boolean isPrivateKeyJwtConfigured(Properties properties) { + String authMethod = properties.getProperty(AUTH_METHOD_PROPERTY); + return AUTH_METHOD_PRIVATE_KEY_JWT.equals(authMethod) + && properties.getProperty(CLIENT_ID_PROPERTY) != null + && properties.getProperty(PRIVATE_KEY_PROPERTY) != null; + } + + /** + * An implementation that loads credentials from a properties file. + */ + public static class FromFile extends JwtClientAssertionProvider { + public FromFile(File file) throws IOException { + super(new SettableSystemClock(), getPropertiesFromFile(file)); + } + + public FromFile(Clock clock, File file) throws IOException { + super(clock, getPropertiesFromFile(file)); + } + + private static Properties getPropertiesFromFile(File file) throws IOException { + try (InputStream inputStream = new FileInputStream(file)) { + Properties properties = new Properties(); + properties.load(inputStream); + return properties; + } + } + } +} diff --git a/here-oauth-client/src/main/java/com/here/account/auth/provider/FromHereCredentialsIniStream.java b/here-oauth-client/src/main/java/com/here/account/auth/provider/FromHereCredentialsIniStream.java index a788ef69..bbdf5692 100644 --- a/here-oauth-client/src/main/java/com/here/account/auth/provider/FromHereCredentialsIniStream.java +++ b/here-oauth-client/src/main/java/com/here/account/auth/provider/FromHereCredentialsIniStream.java @@ -8,6 +8,7 @@ import java.util.Objects; import java.util.Properties; +import com.here.account.auth.JwtClientAssertionProvider; import com.here.account.util.Clock; import com.here.account.util.SettableSystemClock; import org.apache.commons.configuration2.INIConfiguration; @@ -67,6 +68,14 @@ protected static ClientAuthorizationRequestProvider getClientCredentialsProvider String sectionName) { try { Properties properties = getPropertiesFromIni(inputStream, sectionName); + if (JwtClientAssertionProvider.isPrivateKeyJwtConfigured(properties)) { + // Default token endpoint URL if not specified in INI + if (properties.getProperty(JwtClientAssertionProvider.TOKEN_ENDPOINT_URL_PROPERTY) == null) { + properties.setProperty(JwtClientAssertionProvider.TOKEN_ENDPOINT_URL_PROPERTY, + "https://account.api.here.com/oauth2/token"); + } + return new JwtClientAssertionProvider(clock, properties); + } return FromSystemProperties.getClientCredentialsProviderWithDefaultTokenEndpointUrl(clock, properties); } catch (IOException | ConfigurationException e) { throw new RequestProviderException("trouble FromFile " + e, e); @@ -98,6 +107,18 @@ static Properties getPropertiesFromIni(InputStream inputStream, String sectionNa case OAuth1ClientCredentialsProvider.FromProperties.TOKEN_SCOPE_PROPERTY: properties.put(OAuth1ClientCredentialsProvider.FromProperties.TOKEN_SCOPE_PROPERTY, value); break; + case JwtClientAssertionProvider.AUTH_METHOD_PROPERTY: + properties.put(JwtClientAssertionProvider.AUTH_METHOD_PROPERTY, value); + break; + case JwtClientAssertionProvider.CLIENT_ID_PROPERTY: + properties.put(JwtClientAssertionProvider.CLIENT_ID_PROPERTY, value); + break; + case JwtClientAssertionProvider.PRIVATE_KEY_PROPERTY: + properties.put(JwtClientAssertionProvider.PRIVATE_KEY_PROPERTY, value); + break; + case JwtClientAssertionProvider.KEY_ID_PROPERTY: + properties.put(JwtClientAssertionProvider.KEY_ID_PROPERTY, value); + break; } } return properties; @@ -119,7 +140,15 @@ public String getTokenEndpointUrl() { public HttpRequestAuthorizer getClientAuthorizer() { return getDelegate().getClientAuthorizer(); } - + + /** + * {@inheritDoc} + */ + @Override + public com.here.account.oauth2.AccessTokenRequest getNewAccessTokenRequest() { + return getDelegate().getNewAccessTokenRequest(); + } + /** * {@inheritDoc} */ diff --git a/here-oauth-client/src/main/java/com/here/account/auth/provider/FromSystemProperties.java b/here-oauth-client/src/main/java/com/here/account/auth/provider/FromSystemProperties.java index c3bc3faf..82cc3226 100644 --- a/here-oauth-client/src/main/java/com/here/account/auth/provider/FromSystemProperties.java +++ b/here-oauth-client/src/main/java/com/here/account/auth/provider/FromSystemProperties.java @@ -17,6 +17,7 @@ import java.util.Properties; +import com.here.account.auth.JwtClientAssertionProvider; import com.here.account.auth.OAuth1ClientCredentialsProvider; import com.here.account.http.HttpConstants.HttpMethods; import com.here.account.http.HttpProvider.HttpRequestAuthorizer; @@ -57,6 +58,16 @@ static ClientCredentialsProvider getClientCredentialsProviderWithDefaultTokenEnd } static ClientCredentialsProvider getClientCredentialsProviderWithDefaultTokenEndpointUrl(Clock clock, Properties properties) { + // Check if properties indicate OAuth 2.1 private_key_jwt + if (JwtClientAssertionProvider.isPrivateKeyJwtConfigured(properties)) { + // Create a copy to avoid mutating the caller's properties + Properties jwtProps = new Properties(); + jwtProps.putAll(properties); + if (jwtProps.getProperty(JwtClientAssertionProvider.TOKEN_ENDPOINT_URL_PROPERTY) == null) { + jwtProps.setProperty(JwtClientAssertionProvider.TOKEN_ENDPOINT_URL_PROPERTY, DEFAULT_TOKEN_ENDPOINT_URL); + } + return new JwtClientAssertionProviderAdapter(clock, jwtProps); + } return new OAuth1ClientCredentialsProvider( clock, properties.getProperty(OAuth1ClientCredentialsProvider.FromProperties.TOKEN_ENDPOINT_URL_PROPERTY, DEFAULT_TOKEN_ENDPOINT_URL), @@ -66,6 +77,16 @@ static ClientCredentialsProvider getClientCredentialsProviderWithDefaultTokenEnd ); } + /** + * Adapter that wraps JwtClientAssertionProvider as a ClientCredentialsProvider. + */ + private static class JwtClientAssertionProviderAdapter extends JwtClientAssertionProvider + implements ClientCredentialsProvider { + JwtClientAssertionProviderAdapter(Clock clock, Properties properties) { + super(clock, properties); + } + } + /** * {@inheritDoc} */ @@ -81,7 +102,15 @@ public String getTokenEndpointUrl() { public HttpRequestAuthorizer getClientAuthorizer() { return getDelegate().getClientAuthorizer(); } - + + /** + * {@inheritDoc} + */ + @Override + public com.here.account.oauth2.AccessTokenRequest getNewAccessTokenRequest() { + return getDelegate().getNewAccessTokenRequest(); + } + /** * {@inheritDoc} */ diff --git a/here-oauth-client/src/main/java/com/here/account/oauth2/ClientAssertionCredentialsGrantRequest.java b/here-oauth-client/src/main/java/com/here/account/oauth2/ClientAssertionCredentialsGrantRequest.java new file mode 100644 index 00000000..5d98943a --- /dev/null +++ b/here-oauth-client/src/main/java/com/here/account/oauth2/ClientAssertionCredentialsGrantRequest.java @@ -0,0 +1,85 @@ +/* + * Copyright (c) 2025 HERE Europe B.V. + * + * 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.here.account.oauth2; + +import java.util.List; +import java.util.Map; + +/** + * An {@link AccessTokenRequest} for grant_type=client_credentials using + * JWT client assertion authentication per RFC 7523 Section 2.2 and OAuth 2.1. + * + *

+ * The token request includes: + *

    + *
  • {@code grant_type} = {@code client_credentials}
  • + *
  • {@code client_assertion_type} = {@code urn:ietf:params:oauth:client-assertion-type:jwt-bearer}
  • + *
  • {@code client_assertion} = a signed JWT (RS256)
  • + *
+ * + * @see RFC 7523 §2.2 + * @see OAuth 2.1 Draft + */ +public class ClientAssertionCredentialsGrantRequest extends AccessTokenRequest { + + public static final String CLIENT_CREDENTIALS_GRANT_TYPE = "client_credentials"; + + /** + * The client_assertion_type value for JWT Bearer assertions per RFC 7523. + */ + public static final String CLIENT_ASSERTION_TYPE_JWT_BEARER = + "urn:ietf:params:oauth:client-assertion-type:jwt-bearer"; + + protected static final String CLIENT_ASSERTION_TYPE_FORM = "client_assertion_type"; + protected static final String CLIENT_ASSERTION_FORM = "client_assertion"; + + private final String clientAssertion; + + /** + * Construct a new client assertion grant request. + * + * @param clientAssertion the signed JWT client assertion + */ + public ClientAssertionCredentialsGrantRequest(String clientAssertion) { + super(CLIENT_CREDENTIALS_GRANT_TYPE); + if (clientAssertion == null || clientAssertion.isEmpty()) { + throw new IllegalArgumentException("clientAssertion is required"); + } + this.clientAssertion = clientAssertion; + } + + /** + * Gets the client assertion JWT. + * + * @return the signed JWT assertion + */ + public String getClientAssertion() { + return clientAssertion; + } + + /** + * {@inheritDoc} + * + * Adds client_assertion_type and client_assertion to the form parameters. + */ + @Override + public Map> toFormParams() { + Map> formParams = super.toFormParams(); + addFormParam(formParams, CLIENT_ASSERTION_TYPE_FORM, CLIENT_ASSERTION_TYPE_JWT_BEARER); + addFormParam(formParams, CLIENT_ASSERTION_FORM, clientAssertion); + return formParams; + } +} diff --git a/here-oauth-client/src/test/java/com/here/account/auth/JwtClientAssertionBuilderTest.java b/here-oauth-client/src/test/java/com/here/account/auth/JwtClientAssertionBuilderTest.java new file mode 100644 index 00000000..0d440698 --- /dev/null +++ b/here-oauth-client/src/test/java/com/here/account/auth/JwtClientAssertionBuilderTest.java @@ -0,0 +1,207 @@ +/* + * Copyright (c) 2025 HERE Europe B.V. + * + * 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.here.account.auth; + +import com.here.account.util.Clock; +import org.junit.Before; +import org.junit.Test; + +import java.nio.charset.StandardCharsets; +import java.security.*; +import java.util.Base64; +import java.util.concurrent.ScheduledExecutorService; + +import static org.junit.Assert.*; + +public class JwtClientAssertionBuilderTest { + + private KeyPair keyPair; + private PrivateKey privateKey; + private PublicKey publicKey; + private TestClock clock; + + private static final String CLIENT_ID = "test-client-id-12345"; + private static final String TOKEN_ENDPOINT = "https://account.api.here.com/oauth2/token"; + + @Before + public void setUp() throws Exception { + KeyPairGenerator kpg = KeyPairGenerator.getInstance("RSA"); + kpg.initialize(2048); + keyPair = kpg.generateKeyPair(); + privateKey = keyPair.getPrivate(); + publicKey = keyPair.getPublic(); + clock = new TestClock(System.currentTimeMillis()); + } + + @Test + public void testBuildAssertion_producesThreePartJwt() { + JwtClientAssertionBuilder builder = new JwtClientAssertionBuilder(clock, CLIENT_ID, TOKEN_ENDPOINT, privateKey); + String jwt = builder.buildAssertion(); + + String[] parts = jwt.split("\\."); + assertEquals("JWT must have 3 parts", 3, parts.length); + assertFalse("header must not be empty", parts[0].isEmpty()); + assertFalse("payload must not be empty", parts[1].isEmpty()); + assertFalse("signature must not be empty", parts[2].isEmpty()); + } + + @Test + public void testBuildAssertion_headerContainsRS256() { + JwtClientAssertionBuilder builder = new JwtClientAssertionBuilder(clock, CLIENT_ID, TOKEN_ENDPOINT, privateKey); + String jwt = builder.buildAssertion(); + + String header = decodeBase64Url(jwt.split("\\.")[0]); + assertTrue("header must contain RS256", header.contains("\"alg\":\"RS256\"")); + assertTrue("header must contain JWT type", header.contains("\"typ\":\"JWT\"")); + } + + @Test + public void testBuildAssertion_headerContainsKidWhenSet() { + JwtClientAssertionBuilder builder = new JwtClientAssertionBuilder(clock, CLIENT_ID, TOKEN_ENDPOINT, privateKey); + builder.setKid("my-key-id-1"); + String jwt = builder.buildAssertion(); + + String header = decodeBase64Url(jwt.split("\\.")[0]); + assertTrue("header must contain kid", header.contains("\"kid\":\"my-key-id-1\"")); + } + + @Test + public void testBuildAssertion_headerOmitsKidWhenNotSet() { + JwtClientAssertionBuilder builder = new JwtClientAssertionBuilder(clock, CLIENT_ID, TOKEN_ENDPOINT, privateKey); + String jwt = builder.buildAssertion(); + + String header = decodeBase64Url(jwt.split("\\.")[0]); + assertFalse("header must not contain kid when not set", header.contains("kid")); + } + + @Test + public void testBuildAssertion_payloadContainsRequiredClaims() { + long nowMillis = 1700000000000L; + clock.setCurrentTimeMillis(nowMillis); + + JwtClientAssertionBuilder builder = new JwtClientAssertionBuilder(clock, CLIENT_ID, TOKEN_ENDPOINT, privateKey); + String jwt = builder.buildAssertion(); + + String payload = decodeBase64Url(jwt.split("\\.")[1]); + + assertTrue("payload must contain iss", payload.contains("\"iss\":\"" + CLIENT_ID + "\"")); + assertTrue("payload must contain sub", payload.contains("\"sub\":\"" + CLIENT_ID + "\"")); + assertTrue("payload must contain aud", payload.contains("\"aud\":\"" + TOKEN_ENDPOINT + "\"")); + assertTrue("payload must contain iat", payload.contains("\"iat\":" + (nowMillis / 1000))); + assertTrue("payload must contain exp", payload.contains("\"exp\":" + (nowMillis / 1000 + 300))); + assertTrue("payload must contain jti", payload.contains("\"jti\":\"")); + } + + @Test + public void testBuildAssertion_customExpirySeconds() { + long nowMillis = 1700000000000L; + clock.setCurrentTimeMillis(nowMillis); + + JwtClientAssertionBuilder builder = new JwtClientAssertionBuilder(clock, CLIENT_ID, TOKEN_ENDPOINT, privateKey); + builder.setExpirySeconds(60); + String jwt = builder.buildAssertion(); + + String payload = decodeBase64Url(jwt.split("\\.")[1]); + assertTrue("exp should be iat + 60", payload.contains("\"exp\":" + (nowMillis / 1000 + 60))); + } + + @Test + public void testBuildAssertion_signatureIsVerifiable() throws Exception { + JwtClientAssertionBuilder builder = new JwtClientAssertionBuilder(clock, CLIENT_ID, TOKEN_ENDPOINT, privateKey); + String jwt = builder.buildAssertion(); + + String[] parts = jwt.split("\\."); + String signingInput = parts[0] + "." + parts[1]; + byte[] signatureBytes = Base64.getUrlDecoder().decode(parts[2]); + + Signature sig = Signature.getInstance("SHA256withRSA"); + sig.initVerify(publicKey); + sig.update(signingInput.getBytes(StandardCharsets.UTF_8)); + assertTrue("signature must verify with the corresponding public key", sig.verify(signatureBytes)); + } + + @Test + public void testBuildAssertion_uniqueJtiPerCall() { + JwtClientAssertionBuilder builder = new JwtClientAssertionBuilder(clock, CLIENT_ID, TOKEN_ENDPOINT, privateKey); + + String jwt1 = builder.buildAssertion(); + String jwt2 = builder.buildAssertion(); + + String payload1 = decodeBase64Url(jwt1.split("\\.")[1]); + String payload2 = decodeBase64Url(jwt2.split("\\.")[1]); + + // Extract jti values + String jti1 = extractJsonValue(payload1, "jti"); + String jti2 = extractJsonValue(payload2, "jti"); + + assertNotEquals("each assertion must have unique jti", jti1, jti2); + } + + @Test(expected = IllegalArgumentException.class) + public void testSetExpirySeconds_rejectsZero() { + JwtClientAssertionBuilder builder = new JwtClientAssertionBuilder(clock, CLIENT_ID, TOKEN_ENDPOINT, privateKey); + builder.setExpirySeconds(0); + } + + @Test(expected = IllegalArgumentException.class) + public void testSetExpirySeconds_rejectsNegative() { + JwtClientAssertionBuilder builder = new JwtClientAssertionBuilder(clock, CLIENT_ID, TOKEN_ENDPOINT, privateKey); + builder.setExpirySeconds(-1); + } + + @Test(expected = NullPointerException.class) + public void testConstructor_rejectsNullClientId() { + new JwtClientAssertionBuilder(clock, null, TOKEN_ENDPOINT, privateKey); + } + + @Test(expected = NullPointerException.class) + public void testConstructor_rejectsNullPrivateKey() { + new JwtClientAssertionBuilder(clock, CLIENT_ID, TOKEN_ENDPOINT, null); + } + + private static String decodeBase64Url(String encoded) { + return new String(Base64.getUrlDecoder().decode(encoded), java.nio.charset.StandardCharsets.UTF_8); + } + + private static String extractJsonValue(String json, String key) { + String search = "\"" + key + "\":\""; + int start = json.indexOf(search) + search.length(); + int end = json.indexOf("\"", start); + return json.substring(start, end); + } + + private static class TestClock implements Clock { + private long millis; + + TestClock(long millis) { + this.millis = millis; + } + + void setCurrentTimeMillis(long millis) { + this.millis = millis; + } + + @Override + public long currentTimeMillis() { + return millis; + } + + @Override + public void schedule(ScheduledExecutorService scheduledExecutorService, Runnable runnable, + long millisecondsInTheFutureToSchedule) { + } + } +} diff --git a/here-oauth-client/src/test/java/com/here/account/auth/JwtClientAssertionProviderTest.java b/here-oauth-client/src/test/java/com/here/account/auth/JwtClientAssertionProviderTest.java new file mode 100644 index 00000000..d3f389ef --- /dev/null +++ b/here-oauth-client/src/test/java/com/here/account/auth/JwtClientAssertionProviderTest.java @@ -0,0 +1,313 @@ +/* + * Copyright (c) 2025 HERE Europe B.V. + * + * 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.here.account.auth; + +import com.here.account.http.HttpConstants; +import com.here.account.oauth2.AccessTokenRequest; +import com.here.account.oauth2.ClientAssertionCredentialsGrantRequest; +import com.here.account.util.SettableSystemClock; +import org.junit.Before; +import org.junit.Test; + +import java.nio.charset.StandardCharsets; +import java.security.*; +import java.util.Base64; +import java.util.List; +import java.util.Map; +import java.util.Properties; + +import static org.junit.Assert.*; + +public class JwtClientAssertionProviderTest { + + private KeyPair keyPair; + private String pemPrivateKey; + private SettableSystemClock clock; + + private static final String TOKEN_ENDPOINT = "https://account.api.here.com/oauth2/token"; + private static final String CLIENT_ID = "test-app-id-xyz"; + + @Before + public void setUp() throws Exception { + KeyPairGenerator kpg = KeyPairGenerator.getInstance("RSA"); + kpg.initialize(2048); + keyPair = kpg.generateKeyPair(); + pemPrivateKey = toPemPkcs8(keyPair.getPrivate()); + clock = new SettableSystemClock(); + } + + @Test + public void testGetTokenEndpointUrl() { + JwtClientAssertionProvider provider = new JwtClientAssertionProvider( + clock, TOKEN_ENDPOINT, CLIENT_ID, keyPair.getPrivate(), null); + assertEquals(TOKEN_ENDPOINT, provider.getTokenEndpointUrl()); + } + + @Test + public void testGetHttpMethod_isPOST() { + JwtClientAssertionProvider provider = new JwtClientAssertionProvider( + clock, TOKEN_ENDPOINT, CLIENT_ID, keyPair.getPrivate(), null); + assertEquals(HttpConstants.HttpMethods.POST, provider.getHttpMethod()); + } + + @Test + public void testGetClientAuthorizer_isNoAuthorizer() { + JwtClientAssertionProvider provider = new JwtClientAssertionProvider( + clock, TOKEN_ENDPOINT, CLIENT_ID, keyPair.getPrivate(), null); + assertTrue("authorizer should be NoAuthorizer", provider.getClientAuthorizer() instanceof NoAuthorizer); + } + + @Test + public void testGetNewAccessTokenRequest_returnsClientAssertionRequest() { + JwtClientAssertionProvider provider = new JwtClientAssertionProvider( + clock, TOKEN_ENDPOINT, CLIENT_ID, keyPair.getPrivate(), null); + AccessTokenRequest request = provider.getNewAccessTokenRequest(); + assertTrue("request should be ClientAssertionCredentialsGrantRequest", + request instanceof ClientAssertionCredentialsGrantRequest); + } + + @Test + public void testGetNewAccessTokenRequest_formParamsCorrect() { + JwtClientAssertionProvider provider = new JwtClientAssertionProvider( + clock, TOKEN_ENDPOINT, CLIENT_ID, keyPair.getPrivate(), "openid"); + AccessTokenRequest request = provider.getNewAccessTokenRequest(); + Map> formParams = request.toFormParams(); + + assertEquals("client_credentials", formParams.get("grant_type").get(0)); + assertEquals("urn:ietf:params:oauth:client-assertion-type:jwt-bearer", + formParams.get("client_assertion_type").get(0)); + assertNotNull("client_assertion must be present", formParams.get("client_assertion")); + assertEquals("client_assertion must be a JWT", 3, formParams.get("client_assertion").get(0).split("\\.").length); + assertEquals("openid", formParams.get("scope").get(0)); + } + + @Test + public void testGetNewAccessTokenRequest_assertionSignatureValid() throws Exception { + JwtClientAssertionProvider provider = new JwtClientAssertionProvider( + clock, TOKEN_ENDPOINT, CLIENT_ID, keyPair.getPrivate(), null); + AccessTokenRequest request = provider.getNewAccessTokenRequest(); + Map> formParams = request.toFormParams(); + + String jwt = formParams.get("client_assertion").get(0); + String[] parts = jwt.split("\\."); + String signingInput = parts[0] + "." + parts[1]; + byte[] signatureBytes = Base64.getUrlDecoder().decode(parts[2]); + + Signature sig = Signature.getInstance("SHA256withRSA"); + sig.initVerify(keyPair.getPublic()); + sig.update(signingInput.getBytes(StandardCharsets.UTF_8)); + assertTrue("assertion signature must verify with the public key", sig.verify(signatureBytes)); + } + + @Test + public void testFromProperties() { + Properties props = new Properties(); + props.setProperty(JwtClientAssertionProvider.TOKEN_ENDPOINT_URL_PROPERTY, TOKEN_ENDPOINT); + props.setProperty(JwtClientAssertionProvider.CLIENT_ID_PROPERTY, CLIENT_ID); + props.setProperty(JwtClientAssertionProvider.PRIVATE_KEY_PROPERTY, pemPrivateKey); + props.setProperty(JwtClientAssertionProvider.TOKEN_SCOPE_PROPERTY, "test-scope"); + props.setProperty(JwtClientAssertionProvider.KEY_ID_PROPERTY, "kid-123"); + + JwtClientAssertionProvider provider = new JwtClientAssertionProvider(clock, props); + + assertEquals(TOKEN_ENDPOINT, provider.getTokenEndpointUrl()); + assertEquals("test-scope", provider.getScope()); + + // Verify kid is in JWT header + AccessTokenRequest request = provider.getNewAccessTokenRequest(); + String jwt = request.toFormParams().get("client_assertion").get(0); + String header = new String(Base64.getUrlDecoder().decode(jwt.split("\\.")[0])); + assertTrue("header should contain kid", header.contains("\"kid\":\"kid-123\"")); + } + + @Test + public void testIsPrivateKeyJwtConfigured_trueWhenAllPresent() { + Properties props = new Properties(); + props.setProperty(JwtClientAssertionProvider.AUTH_METHOD_PROPERTY, "private_key_jwt"); + props.setProperty(JwtClientAssertionProvider.CLIENT_ID_PROPERTY, CLIENT_ID); + props.setProperty(JwtClientAssertionProvider.PRIVATE_KEY_PROPERTY, pemPrivateKey); + assertTrue(JwtClientAssertionProvider.isPrivateKeyJwtConfigured(props)); + } + + @Test + public void testIsPrivateKeyJwtConfigured_falseWhenOauth1() { + Properties props = new Properties(); + props.setProperty("here.access.key.id", "someId"); + props.setProperty("here.access.key.secret", "someSecret"); + assertFalse(JwtClientAssertionProvider.isPrivateKeyJwtConfigured(props)); + } + + @Test + public void testIsPrivateKeyJwtConfigured_falseWhenMissingClientId() { + Properties props = new Properties(); + props.setProperty(JwtClientAssertionProvider.AUTH_METHOD_PROPERTY, "private_key_jwt"); + props.setProperty(JwtClientAssertionProvider.PRIVATE_KEY_PROPERTY, pemPrivateKey); + assertFalse(JwtClientAssertionProvider.isPrivateKeyJwtConfigured(props)); + } + + @Test + public void testLoadPrivateKey_fromInlinePem() { + PrivateKey key = JwtClientAssertionProvider.loadPrivateKey(pemPrivateKey); + assertNotNull(key); + assertEquals("RSA", key.getAlgorithm()); + } + + @Test(expected = IllegalArgumentException.class) + public void testLoadPrivateKey_fromNonExistentFile() { + JwtClientAssertionProvider.loadPrivateKey("/nonexistent/path/to/key.pem"); + } + + @Test(expected = IllegalArgumentException.class) + public void testLoadPrivateKey_fromInvalidPemContent() { + JwtClientAssertionProvider.loadPrivateKey("-----BEGIN PRIVATE KEY-----\nnotvalidbase64!!!\n-----END PRIVATE KEY-----"); + } + + @Test(expected = NullPointerException.class) + public void testLoadPrivateKey_nullInput() { + JwtClientAssertionProvider.loadPrivateKey(null); + } + + @Test + public void testLoadPrivateKey_fromRawBase64WithoutMarkers() { + // Extract just the base64 from the PEM + String raw = pemPrivateKey + .replace("-----BEGIN PRIVATE KEY-----", "") + .replace("-----END PRIVATE KEY-----", "") + .replaceAll("\\s+", ""); + // raw starts with "MII" so loadPrivateKey should detect it as inline + PrivateKey key = JwtClientAssertionProvider.loadPrivateKey(raw); + assertNotNull(key); + assertEquals("RSA", key.getAlgorithm()); + } + + @Test + public void testEachCallProducesUniqueAssertion() { + JwtClientAssertionProvider provider = new JwtClientAssertionProvider( + clock, TOKEN_ENDPOINT, CLIENT_ID, keyPair.getPrivate(), null); + String jwt1 = provider.getNewAccessTokenRequest().toFormParams().get("client_assertion").get(0); + String jwt2 = provider.getNewAccessTokenRequest().toFormParams().get("client_assertion").get(0); + assertNotEquals("each call must produce a unique assertion (different jti)", jwt1, jwt2); + } + + @Test + public void testFromSystemProperties_detectsPrivateKeyJwt() { + Properties props = new Properties(); + props.setProperty(JwtClientAssertionProvider.AUTH_METHOD_PROPERTY, "private_key_jwt"); + props.setProperty(JwtClientAssertionProvider.CLIENT_ID_PROPERTY, CLIENT_ID); + props.setProperty(JwtClientAssertionProvider.PRIVATE_KEY_PROPERTY, pemPrivateKey); + props.setProperty(JwtClientAssertionProvider.TOKEN_ENDPOINT_URL_PROPERTY, TOKEN_ENDPOINT); + + // Use the provider directly (which internally uses FromSystemProperties logic) + JwtClientAssertionProvider result = new JwtClientAssertionProvider(clock, props); + + assertEquals(TOKEN_ENDPOINT, result.getTokenEndpointUrl()); + assertTrue("authorizer should be NoAuthorizer", + result.getClientAuthorizer() instanceof NoAuthorizer); + assertTrue("should produce ClientAssertionCredentialsGrantRequest", + result.getNewAccessTokenRequest() + instanceof com.here.account.oauth2.ClientAssertionCredentialsGrantRequest); + } + + @Test + public void testFromProperties_defaultsTokenEndpointWhenMissing() { + // JwtClientAssertionProvider requires non-null tokenEndpointUrl, + // but the FromSystemProperties/FromHereCredentialsIniStream wrappers + // default it. Test via the INI path which is accessible. + Properties props = new Properties(); + props.setProperty(JwtClientAssertionProvider.AUTH_METHOD_PROPERTY, "private_key_jwt"); + props.setProperty(JwtClientAssertionProvider.CLIENT_ID_PROPERTY, CLIENT_ID); + props.setProperty(JwtClientAssertionProvider.PRIVATE_KEY_PROPERTY, pemPrivateKey); + // No TOKEN_ENDPOINT_URL_PROPERTY + + // The INI stream path defaults the endpoint + String ini = "[default]\n" + + "here.auth.method = private_key_jwt\n" + + "here.client.id = " + CLIENT_ID + "\n" + + "here.private.key = " + pemPrivateKey.replace("\n", "") + "\n"; + + // Use file-based approach for this test + try { + java.io.File tempKey = java.io.File.createTempFile("test-key", ".pem"); + tempKey.deleteOnExit(); + try (java.io.FileWriter fw = new java.io.FileWriter(tempKey)) { + fw.write(pemPrivateKey); + } + String ini2 = "[default]\n" + + "here.auth.method = private_key_jwt\n" + + "here.client.id = " + CLIENT_ID + "\n" + + "here.private.key = " + tempKey.getAbsolutePath() + "\n"; + + java.io.InputStream stream = new java.io.ByteArrayInputStream( + ini2.getBytes(java.nio.charset.StandardCharsets.UTF_8)); + com.here.account.auth.provider.FromHereCredentialsIniStream iniProvider = + new com.here.account.auth.provider.FromHereCredentialsIniStream(clock, stream); + + assertEquals("should default to production endpoint", + "https://account.api.here.com/oauth2/token", iniProvider.getTokenEndpointUrl()); + } catch (Exception e) { + fail("unexpected exception: " + e.getMessage()); + } + } + + @Test + public void testFromProperties_returnsOAuth1WhenNoJwtConfig() { + // When auth.method is NOT private_key_jwt, isPrivateKeyJwtConfigured returns false + Properties props = new Properties(); + props.setProperty("here.access.key.id", "someId"); + props.setProperty("here.access.key.secret", "someSecret"); + assertFalse(JwtClientAssertionProvider.isPrivateKeyJwtConfigured(props)); + } + + @Test + public void testFromIniStream_detectsPrivateKeyJwt() throws Exception { + java.io.File tempKey = java.io.File.createTempFile("test-jwt-key", ".pem"); + tempKey.deleteOnExit(); + try (java.io.FileWriter fw = new java.io.FileWriter(tempKey)) { + fw.write(pemPrivateKey); + } + + String ini = "[default]\n" + + "here.token.endpoint.url = " + TOKEN_ENDPOINT + "\n" + + "here.auth.method = private_key_jwt\n" + + "here.client.id = " + CLIENT_ID + "\n" + + "here.private.key = " + tempKey.getAbsolutePath() + "\n"; + + java.io.InputStream stream = new java.io.ByteArrayInputStream( + ini.getBytes(java.nio.charset.StandardCharsets.UTF_8)); + + com.here.account.auth.provider.FromHereCredentialsIniStream provider = + new com.here.account.auth.provider.FromHereCredentialsIniStream(clock, stream); + + assertEquals(TOKEN_ENDPOINT, provider.getTokenEndpointUrl()); + assertTrue("authorizer should be NoAuthorizer", + provider.getClientAuthorizer() instanceof NoAuthorizer); + assertTrue("should return ClientAssertionCredentialsGrantRequest", + provider.getNewAccessTokenRequest() + instanceof com.here.account.oauth2.ClientAssertionCredentialsGrantRequest); + } + + private static String toPemPkcs8(PrivateKey privateKey) { + String base64 = Base64.getEncoder().encodeToString(privateKey.getEncoded()); + StringBuilder sb = new StringBuilder(); + sb.append("-----BEGIN PRIVATE KEY-----\n"); + for (int i = 0; i < base64.length(); i += 64) { + sb.append(base64, i, Math.min(i + 64, base64.length())); + sb.append('\n'); + } + sb.append("-----END PRIVATE KEY-----\n"); + return sb.toString(); + } +} diff --git a/here-oauth-client/src/test/java/com/here/account/oauth2/ClientAssertionCredentialsGrantRequestTest.java b/here-oauth-client/src/test/java/com/here/account/oauth2/ClientAssertionCredentialsGrantRequestTest.java new file mode 100644 index 00000000..5a1d102e --- /dev/null +++ b/here-oauth-client/src/test/java/com/here/account/oauth2/ClientAssertionCredentialsGrantRequestTest.java @@ -0,0 +1,92 @@ +/* + * Copyright (c) 2025 HERE Europe B.V. + * + * 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.here.account.oauth2; + +import org.junit.Test; + +import java.util.List; +import java.util.Map; + +import static org.junit.Assert.*; + +public class ClientAssertionCredentialsGrantRequestTest { + + private static final String FAKE_JWT = "eyJhbGciOiJSUzI1NiJ9.eyJpc3MiOiJ0ZXN0In0.signature"; + + @Test + public void testFormParams_containsGrantType() { + ClientAssertionCredentialsGrantRequest request = new ClientAssertionCredentialsGrantRequest(FAKE_JWT); + Map> formParams = request.toFormParams(); + + assertEquals("client_credentials", formParams.get("grant_type").get(0)); + } + + @Test + public void testFormParams_containsClientAssertionType() { + ClientAssertionCredentialsGrantRequest request = new ClientAssertionCredentialsGrantRequest(FAKE_JWT); + Map> formParams = request.toFormParams(); + + assertEquals("urn:ietf:params:oauth:client-assertion-type:jwt-bearer", + formParams.get("client_assertion_type").get(0)); + } + + @Test + public void testFormParams_containsClientAssertion() { + ClientAssertionCredentialsGrantRequest request = new ClientAssertionCredentialsGrantRequest(FAKE_JWT); + Map> formParams = request.toFormParams(); + + assertEquals(FAKE_JWT, formParams.get("client_assertion").get(0)); + } + + @Test + public void testFormParams_containsScope_whenSet() { + ClientAssertionCredentialsGrantRequest request = new ClientAssertionCredentialsGrantRequest(FAKE_JWT); + request.setScope("openid"); + Map> formParams = request.toFormParams(); + + assertEquals("openid", formParams.get("scope").get(0)); + } + + @Test + public void testFormParams_noScope_whenNotSet() { + ClientAssertionCredentialsGrantRequest request = new ClientAssertionCredentialsGrantRequest(FAKE_JWT); + Map> formParams = request.toFormParams(); + + assertNull(formParams.get("scope")); + } + + @Test + public void testGetGrantType() { + ClientAssertionCredentialsGrantRequest request = new ClientAssertionCredentialsGrantRequest(FAKE_JWT); + assertEquals("client_credentials", request.getGrantType()); + } + + @Test + public void testGetClientAssertion() { + ClientAssertionCredentialsGrantRequest request = new ClientAssertionCredentialsGrantRequest(FAKE_JWT); + assertEquals(FAKE_JWT, request.getClientAssertion()); + } + + @Test(expected = IllegalArgumentException.class) + public void testConstructor_rejectsNull() { + new ClientAssertionCredentialsGrantRequest(null); + } + + @Test(expected = IllegalArgumentException.class) + public void testConstructor_rejectsEmpty() { + new ClientAssertionCredentialsGrantRequest(""); + } +} diff --git a/here-oauth-client/src/test/java/com/here/account/oauth2/JwtClientAssertionIT.java b/here-oauth-client/src/test/java/com/here/account/oauth2/JwtClientAssertionIT.java new file mode 100644 index 00000000..70e37b28 --- /dev/null +++ b/here-oauth-client/src/test/java/com/here/account/oauth2/JwtClientAssertionIT.java @@ -0,0 +1,535 @@ +/* + * Copyright (c) 2025 HERE Europe B.V. + * + * 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.here.account.oauth2; + +import com.here.account.auth.JwtClientAssertionBuilder; +import com.here.account.auth.JwtClientAssertionProvider; +import com.here.account.auth.NoAuthorizer; +import com.here.account.http.HttpProvider; +import com.here.account.http.java.JavaHttpProvider; +import com.here.account.util.Clock; +import com.here.account.util.SettableSystemClock; +import org.junit.After; +import org.junit.Assume; +import org.junit.Before; +import org.junit.Test; + +import java.io.File; +import java.io.FileInputStream; +import java.io.IOException; +import java.security.KeyPair; +import java.security.KeyPairGenerator; +import java.security.PrivateKey; +import java.util.*; +import java.util.concurrent.ScheduledExecutorService; + +import static org.junit.Assert.*; + +/** + * Integration test for OAuth 2.1 private_key_jwt client authentication. + * + *

Prerequisites (one-time manual setup): + *

    + *
  1. Generate an RSA key pair (see JwtTestSetupUtil)
  2. + *
  3. Register the public key (n, e) on the HERE Account server via + * POST /authentication/v1.1/apps/{appHRN}/jwks
  4. + *
  5. Create ~/.here/jwt-credentials.properties with: + *
    + *       here.token.endpoint.url=https://stg.account.api.here.com/oauth2/token
    + *       here.auth.method=private_key_jwt
    + *       here.client.id=M406CQl58iM5RhpHgNNp
    + *       here.private.key=/path/to/jwt-test-private-key.pem
    + *       here.key.id=jwt-test-key-1
    + *       
    + *
  6. + *
+ * + *

Run with: {@code mvn test -Dtest=JwtClientAssertionIT -DjwtCredentialsFile=/path/to/jwt-credentials.properties} + */ +public class JwtClientAssertionIT { + + private static final String JWT_CREDENTIALS_FILE_PROPERTY = "jwtCredentialsFile"; + private static final String DEFAULT_JWT_CREDENTIALS_PATH = System.getProperty("user.home") + + File.separator + ".here" + File.separator + "jwt-credentials.properties"; + + private HttpProvider httpProvider; + private JwtClientAssertionProvider provider; + private Properties props; + private SettableSystemClock clock; + + @Before + public void setUp() throws Exception { + // Try system properties first (for CI side-loading) + String sysPropClientId = System.getProperty("here.jwt.client.id"); + String sysPropPrivateKey = System.getProperty("here.jwt.private.key"); + String sysPropKeyId = System.getProperty("here.jwt.key.id"); + String sysPropTokenUrl = System.getProperty("here.token.endpoint.url", + "https://stg.account.api.here.com/oauth2/token"); + String sysPropScope = System.getProperty("here.jwt.token.scope"); + + if (sysPropClientId != null && sysPropPrivateKey != null) { + props = new Properties(); + props.setProperty(JwtClientAssertionProvider.TOKEN_ENDPOINT_URL_PROPERTY, sysPropTokenUrl); + props.setProperty(JwtClientAssertionProvider.AUTH_METHOD_PROPERTY, "private_key_jwt"); + props.setProperty(JwtClientAssertionProvider.CLIENT_ID_PROPERTY, sysPropClientId); + props.setProperty(JwtClientAssertionProvider.PRIVATE_KEY_PROPERTY, sysPropPrivateKey); + if (sysPropKeyId != null) { + props.setProperty(JwtClientAssertionProvider.KEY_ID_PROPERTY, sysPropKeyId); + } + if (sysPropScope != null) { + props.setProperty(JwtClientAssertionProvider.TOKEN_SCOPE_PROPERTY, sysPropScope); + } + } else { + // Fall back to credentials file + File credFile = getCredentialsFile(); + Assume.assumeTrue( + "Skipping JWT IT: no credentials found. Set -Dhere.jwt.client.id and -Dhere.jwt.private.key, " + + "or place credentials at " + credFile.getAbsolutePath(), + credFile.exists()); + + props = new Properties(); + try (FileInputStream fis = new FileInputStream(credFile)) { + props.load(fis); + } + } + + Assume.assumeTrue("Credentials must have here.auth.method=private_key_jwt", + JwtClientAssertionProvider.isPrivateKeyJwtConfigured(props)); + + clock = new SettableSystemClock(); + provider = new JwtClientAssertionProvider(clock, props); + httpProvider = JavaHttpProvider.builder().build(); + } + + @After + public void tearDown() throws IOException { + if (httpProvider != null) { + httpProvider.close(); + } + } + + // ======================== + // Positive tests + // ======================== + + @Test + public void test_clientAssertion_getAccessToken() { + TokenEndpoint tokenEndpoint = HereAccount.getTokenEndpoint(httpProvider, provider); + AccessTokenResponse response = tokenEndpoint.requestToken(provider.getNewAccessTokenRequest()); + + assertNotNull("response must not be null", response); + String accessToken = response.getAccessToken(); + assertNotNull("accessToken must not be null", accessToken); + assertFalse("accessToken must not be blank", accessToken.trim().isEmpty()); + assertEquals("tokenType must be bearer", "bearer", response.getTokenType()); + assertTrue("expiresIn must be positive", response.getExpiresIn() > 0); + } + + @Test + public void test_clientAssertion_withHereAccessTokenProvider() throws IOException { + try (HereAccessTokenProvider accessTokens = HereAccessTokenProvider.builder() + .setClientAuthorizationRequestProvider(provider) + .build()) { + String accessToken = accessTokens.getAccessToken(); + assertNotNull("accessToken must not be null", accessToken); + assertFalse("accessToken must not be blank", accessToken.trim().isEmpty()); + } + } + + @Test + public void test_clientAssertion_alwaysRequestNewToken() throws IOException { + try (HereAccessTokenProvider accessTokens = HereAccessTokenProvider.builder() + .setClientAuthorizationRequestProvider(provider) + .setAlwaysRequestNewToken(true) + .build()) { + String token1 = accessTokens.getAccessToken(); + String token2 = accessTokens.getAccessToken(); + assertNotNull(token1); + assertNotNull(token2); + } + } + + @Test + public void test_clientAssertion_customExpiresIn() { + TokenEndpoint tokenEndpoint = HereAccount.getTokenEndpoint(httpProvider, provider); + AccessTokenRequest request = provider.getNewAccessTokenRequest(); + request.setExpiresIn(15L); + + AccessTokenResponse response = tokenEndpoint.requestToken(request); + assertNotNull(response); + assertTrue("expiresIn should be <= 15", response.getExpiresIn() <= 15); + } + + /** + * Test: client_id is OPTIONAL in the request body. + * The server identifies the client from the JWT's iss claim alone. + */ + @Test + public void test_clientAssertion_withoutClientIdInBody() { + String clientId = props.getProperty(JwtClientAssertionProvider.CLIENT_ID_PROPERTY); + String tokenEndpointUrl = props.getProperty(JwtClientAssertionProvider.TOKEN_ENDPOINT_URL_PROPERTY); + PrivateKey privateKey = JwtClientAssertionProvider.loadPrivateKey( + props.getProperty(JwtClientAssertionProvider.PRIVATE_KEY_PROPERTY)); + String kid = props.getProperty(JwtClientAssertionProvider.KEY_ID_PROPERTY); + + // Build assertion manually — the form params will NOT include client_id + JwtClientAssertionBuilder builder = new JwtClientAssertionBuilder(clock, clientId, tokenEndpointUrl, privateKey); + if (kid != null) { + builder.setKid(kid); + } + String assertion = builder.buildAssertion(); + + // Build form params without client_id — only grant_type, client_assertion_type, client_assertion + Map> formParams = new HashMap<>(); + formParams.put("grant_type", Collections.singletonList("client_credentials")); + formParams.put("client_assertion_type", + Collections.singletonList("urn:ietf:params:oauth:client-assertion-type:jwt-bearer")); + formParams.put("client_assertion", Collections.singletonList(assertion)); + + // Send directly + HttpProvider.HttpRequest httpRequest = httpProvider.getRequest( + new NoAuthorizer(), "POST", tokenEndpointUrl, formParams); + + try { + HttpProvider.HttpResponse response = httpProvider.execute(httpRequest); + assertEquals("should succeed without client_id in body", 200, response.getStatusCode()); + } catch (Exception e) { + fail("Request without client_id should succeed: " + e.getMessage()); + } + } + + /** + * Test: kid is OPTIONAL in the JWT header. + * When absent, the server tries all registered JWKs for the app. + */ + @Test + public void test_clientAssertion_withoutKidInHeader() { + String clientId = props.getProperty(JwtClientAssertionProvider.CLIENT_ID_PROPERTY); + String tokenEndpointUrl = props.getProperty(JwtClientAssertionProvider.TOKEN_ENDPOINT_URL_PROPERTY); + PrivateKey privateKey = JwtClientAssertionProvider.loadPrivateKey( + props.getProperty(JwtClientAssertionProvider.PRIVATE_KEY_PROPERTY)); + + // Build provider WITHOUT kid + JwtClientAssertionProvider noKidProvider = new JwtClientAssertionProvider( + clock, tokenEndpointUrl, clientId, privateKey, null, null); + + TokenEndpoint tokenEndpoint = HereAccount.getTokenEndpoint(httpProvider, noKidProvider); + AccessTokenResponse response = tokenEndpoint.requestToken(noKidProvider.getNewAccessTokenRequest()); + + assertNotNull("response must not be null", response); + assertNotNull("accessToken must not be null", response.getAccessToken()); + assertFalse("accessToken must not be blank", response.getAccessToken().trim().isEmpty()); + } + + /** + * Test: request a project-scoped token using the scope parameter. + * Requires the app to be a member of the project specified in + * the 'here.token.scope' property of the credentials file. + * + *

If here.token.scope is not configured, this test is skipped. + */ + @Test + public void test_clientAssertion_withScope() { + String scope = props.getProperty(JwtClientAssertionProvider.TOKEN_SCOPE_PROPERTY); + Assume.assumeTrue("Skipping scope test: here.token.scope not set in credentials", + scope != null && !scope.isEmpty()); + + String clientId = props.getProperty(JwtClientAssertionProvider.CLIENT_ID_PROPERTY); + String tokenEndpointUrl = props.getProperty(JwtClientAssertionProvider.TOKEN_ENDPOINT_URL_PROPERTY); + PrivateKey privateKey = JwtClientAssertionProvider.loadPrivateKey( + props.getProperty(JwtClientAssertionProvider.PRIVATE_KEY_PROPERTY)); + String kid = props.getProperty(JwtClientAssertionProvider.KEY_ID_PROPERTY); + + JwtClientAssertionProvider scopedProvider = new JwtClientAssertionProvider( + clock, tokenEndpointUrl, clientId, privateKey, scope, kid); + + TokenEndpoint tokenEndpoint = HereAccount.getTokenEndpoint(httpProvider, scopedProvider); + AccessTokenResponse response = tokenEndpoint.requestToken(scopedProvider.getNewAccessTokenRequest()); + + assertNotNull("response must not be null", response); + assertNotNull("accessToken must not be null", response.getAccessToken()); + assertFalse("accessToken must not be blank", response.getAccessToken().trim().isEmpty()); + } + + // ======================== + // Negative tests + // ======================== + + /** + * Negative test: signing with a WRONG private key (not registered on server). + * Expected: 401 - ClientAssertionSignatureInvalid (401952) + */ + @Test + public void test_clientAssertion_wrongKey_returns401() throws Exception { + String clientId = props.getProperty(JwtClientAssertionProvider.CLIENT_ID_PROPERTY); + String tokenEndpointUrl = props.getProperty(JwtClientAssertionProvider.TOKEN_ENDPOINT_URL_PROPERTY); + + // Generate a random key pair NOT registered on the server + KeyPairGenerator kpg = KeyPairGenerator.getInstance("RSA"); + kpg.initialize(2048); + KeyPair wrongKeyPair = kpg.generateKeyPair(); + + JwtClientAssertionProvider wrongProvider = new JwtClientAssertionProvider( + clock, tokenEndpointUrl, clientId, wrongKeyPair.getPrivate(), null, null); + + TokenEndpoint tokenEndpoint = HereAccount.getTokenEndpoint(httpProvider, wrongProvider); + try { + tokenEndpoint.requestToken(wrongProvider.getNewAccessTokenRequest()); + fail("Should have thrown AccessTokenException for wrong key"); + } catch (AccessTokenException e) { + assertEquals("expected 401 status", 401, e.getStatusCode()); + // 401952 = ClientAssertionSignatureInvalid + assertEquals("expected errorCode 401952", Integer.valueOf(401952), e.getErrorResponse().getErrorCode()); + } + } + + /** + * Negative test: expired JWT (iat and exp in the past). + * Expected: 401 - ClientAssertionJwtExpired (401933) + */ + @Test + public void test_clientAssertion_expiredJwt_returns401() { + String clientId = props.getProperty(JwtClientAssertionProvider.CLIENT_ID_PROPERTY); + String tokenEndpointUrl = props.getProperty(JwtClientAssertionProvider.TOKEN_ENDPOINT_URL_PROPERTY); + PrivateKey privateKey = JwtClientAssertionProvider.loadPrivateKey( + props.getProperty(JwtClientAssertionProvider.PRIVATE_KEY_PROPERTY)); + String kid = props.getProperty(JwtClientAssertionProvider.KEY_ID_PROPERTY); + + // Use a clock set 10 minutes in the past (assertion will be expired by the time server sees it) + Clock pastClock = new Clock() { + @Override + public long currentTimeMillis() { + return System.currentTimeMillis() - (10 * 60 * 1000L); + } + @Override + public void schedule(ScheduledExecutorService s, Runnable r, long ms) {} + }; + + JwtClientAssertionBuilder builder = new JwtClientAssertionBuilder(pastClock, clientId, tokenEndpointUrl, privateKey); + if (kid != null) builder.setKid(kid); + // Set expiry to 1 second so iat-10min + 1s = still well in the past + builder.setExpirySeconds(1); + String assertion = builder.buildAssertion(); + + ClientAssertionCredentialsGrantRequest request = new ClientAssertionCredentialsGrantRequest(assertion); + + TokenEndpoint tokenEndpoint = HereAccount.getTokenEndpoint(httpProvider, provider); + try { + tokenEndpoint.requestToken(request); + fail("Should have thrown AccessTokenException for expired JWT"); + } catch (AccessTokenException e) { + assertEquals("expected 401 status", 401, e.getStatusCode()); + // 401933 = ClientAssertionJwtExpired + assertEquals("expected errorCode 401933", Integer.valueOf(401933), e.getErrorResponse().getErrorCode()); + } + } + + /** + * Negative test: wrong audience (aud != token endpoint URL). + * Expected: 400 or 401 - ClientAssertionAudInvalid (401932) + */ + @Test + public void test_clientAssertion_wrongAudience_returns401() { + String clientId = props.getProperty(JwtClientAssertionProvider.CLIENT_ID_PROPERTY); + String tokenEndpointUrl = props.getProperty(JwtClientAssertionProvider.TOKEN_ENDPOINT_URL_PROPERTY); + PrivateKey privateKey = JwtClientAssertionProvider.loadPrivateKey( + props.getProperty(JwtClientAssertionProvider.PRIVATE_KEY_PROPERTY)); + String kid = props.getProperty(JwtClientAssertionProvider.KEY_ID_PROPERTY); + + // Build JWT with wrong audience + String wrongAud = "https://wrong.example.com/oauth2/token"; + JwtClientAssertionBuilder builder = new JwtClientAssertionBuilder(clock, clientId, wrongAud, privateKey); + if (kid != null) builder.setKid(kid); + String assertion = builder.buildAssertion(); + + ClientAssertionCredentialsGrantRequest request = new ClientAssertionCredentialsGrantRequest(assertion); + + // We still need to POST to the real token endpoint + JwtClientAssertionProvider realProvider = new JwtClientAssertionProvider( + clock, tokenEndpointUrl, clientId, privateKey, null, kid); + TokenEndpoint tokenEndpoint = HereAccount.getTokenEndpoint(httpProvider, realProvider); + try { + tokenEndpoint.requestToken(request); + fail("Should have thrown AccessTokenException for wrong aud"); + } catch (AccessTokenException e) { + // Server returns 400 or 401 for aud mismatch + assertTrue("expected 400 or 401 status", e.getStatusCode() == 400 || e.getStatusCode() == 401); + } + } + + /** + * Negative test: kid in JWT header does not match any registered JWK. + * Expected: 401 - ClientAssertionKidNotFound (401953) + */ + @Test + public void test_clientAssertion_unknownKid_returns401() { + String clientId = props.getProperty(JwtClientAssertionProvider.CLIENT_ID_PROPERTY); + String tokenEndpointUrl = props.getProperty(JwtClientAssertionProvider.TOKEN_ENDPOINT_URL_PROPERTY); + PrivateKey privateKey = JwtClientAssertionProvider.loadPrivateKey( + props.getProperty(JwtClientAssertionProvider.PRIVATE_KEY_PROPERTY)); + + // Use a kid that doesn't exist on the server + JwtClientAssertionBuilder builder = new JwtClientAssertionBuilder(clock, clientId, tokenEndpointUrl, privateKey); + builder.setKid("non-existent-kid-xyz"); + String assertion = builder.buildAssertion(); + + ClientAssertionCredentialsGrantRequest request = new ClientAssertionCredentialsGrantRequest(assertion); + + TokenEndpoint tokenEndpoint = HereAccount.getTokenEndpoint(httpProvider, provider); + try { + tokenEndpoint.requestToken(request); + fail("Should have thrown AccessTokenException for unknown kid"); + } catch (AccessTokenException e) { + assertEquals("expected 401 status", 401, e.getStatusCode()); + // 401953 = ClientAssertionKidNotFound + assertEquals("expected errorCode 401953", Integer.valueOf(401953), e.getErrorResponse().getErrorCode()); + } + } + + /** + * Negative test: replay the same JWT assertion twice. + * The server uses jti for replay prevention (Redis SETNX). + * Expected: 401 - ClientAssertionJTIRepeated (401934) + */ + @Test + public void test_clientAssertion_replayedJwt_returns401() { + String clientId = props.getProperty(JwtClientAssertionProvider.CLIENT_ID_PROPERTY); + String tokenEndpointUrl = props.getProperty(JwtClientAssertionProvider.TOKEN_ENDPOINT_URL_PROPERTY); + PrivateKey privateKey = JwtClientAssertionProvider.loadPrivateKey( + props.getProperty(JwtClientAssertionProvider.PRIVATE_KEY_PROPERTY)); + String kid = props.getProperty(JwtClientAssertionProvider.KEY_ID_PROPERTY); + + // Build a single assertion + JwtClientAssertionBuilder builder = new JwtClientAssertionBuilder(clock, clientId, tokenEndpointUrl, privateKey); + if (kid != null) builder.setKid(kid); + String assertion = builder.buildAssertion(); + + // First request should succeed + ClientAssertionCredentialsGrantRequest request1 = new ClientAssertionCredentialsGrantRequest(assertion); + TokenEndpoint tokenEndpoint = HereAccount.getTokenEndpoint(httpProvider, provider); + AccessTokenResponse response = tokenEndpoint.requestToken(request1); + assertNotNull("first request should succeed", response.getAccessToken()); + + // Second request with the SAME assertion (same jti) should be rejected + ClientAssertionCredentialsGrantRequest request2 = new ClientAssertionCredentialsGrantRequest(assertion); + try { + tokenEndpoint.requestToken(request2); + fail("Should have thrown AccessTokenException for replayed JWT (same jti)"); + } catch (AccessTokenException e) { + assertEquals("expected 401 status", 401, e.getStatusCode()); + // 401934 = ClientAssertionJTIRepeated + assertEquals("expected errorCode 401934", Integer.valueOf(401934), e.getErrorResponse().getErrorCode()); + } + } + + /** + * Test: HereAccessTokenProvider.builder().build() picks up private_key_jwt + * from system properties via the ClientAuthorizationProviderChain. + * This simulates a production user setting -Dhere.auth.method=private_key_jwt etc. + */ + @Test + public void test_clientAssertion_viaProviderChainSystemProperties() throws IOException { + // Set system properties that ClientAuthorizationProviderChain will detect + String previousAuthMethod = System.getProperty(JwtClientAssertionProvider.AUTH_METHOD_PROPERTY); + String previousClientId = System.getProperty(JwtClientAssertionProvider.CLIENT_ID_PROPERTY); + String previousPrivateKey = System.getProperty(JwtClientAssertionProvider.PRIVATE_KEY_PROPERTY); + String previousKeyId = System.getProperty(JwtClientAssertionProvider.KEY_ID_PROPERTY); + String previousTokenUrl = System.getProperty(JwtClientAssertionProvider.TOKEN_ENDPOINT_URL_PROPERTY); + + try { + System.setProperty(JwtClientAssertionProvider.AUTH_METHOD_PROPERTY, "private_key_jwt"); + System.setProperty(JwtClientAssertionProvider.CLIENT_ID_PROPERTY, + props.getProperty(JwtClientAssertionProvider.CLIENT_ID_PROPERTY)); + System.setProperty(JwtClientAssertionProvider.PRIVATE_KEY_PROPERTY, + props.getProperty(JwtClientAssertionProvider.PRIVATE_KEY_PROPERTY)); + System.setProperty(JwtClientAssertionProvider.TOKEN_ENDPOINT_URL_PROPERTY, + props.getProperty(JwtClientAssertionProvider.TOKEN_ENDPOINT_URL_PROPERTY)); + String kid = props.getProperty(JwtClientAssertionProvider.KEY_ID_PROPERTY); + if (kid != null) { + System.setProperty(JwtClientAssertionProvider.KEY_ID_PROPERTY, kid); + } + + // This is the production path: builder().build() should auto-detect private_key_jwt + try (HereAccessTokenProvider accessTokens = HereAccessTokenProvider.builder() + .setAlwaysRequestNewToken(true) + .build()) { + String accessToken = accessTokens.getAccessToken(); + assertNotNull("accessToken must not be null", accessToken); + assertFalse("accessToken must not be blank", accessToken.trim().isEmpty()); + } + } finally { + // Restore previous system properties + restoreProperty(JwtClientAssertionProvider.AUTH_METHOD_PROPERTY, previousAuthMethod); + restoreProperty(JwtClientAssertionProvider.CLIENT_ID_PROPERTY, previousClientId); + restoreProperty(JwtClientAssertionProvider.PRIVATE_KEY_PROPERTY, previousPrivateKey); + restoreProperty(JwtClientAssertionProvider.KEY_ID_PROPERTY, previousKeyId); + restoreProperty(JwtClientAssertionProvider.TOKEN_ENDPOINT_URL_PROPERTY, previousTokenUrl); + } + } + + /** + * Test: credentials loaded from INI format stream (simulates ~/.here/credentials.ini). + */ + @Test + public void test_clientAssertion_viaIniStream() throws IOException { + String tokenUrl = props.getProperty(JwtClientAssertionProvider.TOKEN_ENDPOINT_URL_PROPERTY); + String clientId = props.getProperty(JwtClientAssertionProvider.CLIENT_ID_PROPERTY); + String privateKeyValue = props.getProperty(JwtClientAssertionProvider.PRIVATE_KEY_PROPERTY); + String keyId = props.getProperty(JwtClientAssertionProvider.KEY_ID_PROPERTY); + + // Build INI content + StringBuilder ini = new StringBuilder(); + ini.append("[default]\n"); + ini.append("here.token.endpoint.url = ").append(tokenUrl).append("\n"); + ini.append("here.auth.method = private_key_jwt\n"); + ini.append("here.client.id = ").append(clientId).append("\n"); + ini.append("here.private.key = ").append(privateKeyValue).append("\n"); + if (keyId != null) { + ini.append("here.key.id = ").append(keyId).append("\n"); + } + + java.io.InputStream iniStream = new java.io.ByteArrayInputStream( + ini.toString().getBytes(java.nio.charset.StandardCharsets.UTF_8)); + + com.here.account.auth.provider.FromHereCredentialsIniStream iniProvider = + new com.here.account.auth.provider.FromHereCredentialsIniStream(clock, iniStream); + + try (HereAccessTokenProvider accessTokens = HereAccessTokenProvider.builder() + .setClientAuthorizationRequestProvider(iniProvider) + .setAlwaysRequestNewToken(true) + .build()) { + String accessToken = accessTokens.getAccessToken(); + assertNotNull("accessToken must not be null", accessToken); + assertFalse("accessToken must not be blank", accessToken.trim().isEmpty()); + } + } + + private static void restoreProperty(String key, String previousValue) { + if (previousValue == null) { + System.clearProperty(key); + } else { + System.setProperty(key, previousValue); + } + } + + private File getCredentialsFile() { + String path = System.getProperty(JWT_CREDENTIALS_FILE_PROPERTY); + if (path != null && !path.isEmpty()) { + return new File(path); + } + return new File(DEFAULT_JWT_CREDENTIALS_PATH); + } +} diff --git a/here-oauth-client/src/test/java/com/here/account/oauth2/JwtTestSetupUtil.java b/here-oauth-client/src/test/java/com/here/account/oauth2/JwtTestSetupUtil.java new file mode 100644 index 00000000..4cdcfb04 --- /dev/null +++ b/here-oauth-client/src/test/java/com/here/account/oauth2/JwtTestSetupUtil.java @@ -0,0 +1,185 @@ +/* + * Copyright (c) 2025 HERE Europe B.V. + * + * 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.here.account.oauth2; + +import java.io.File; +import java.io.FileWriter; +import java.io.UnsupportedEncodingException; +import java.math.BigInteger; +import java.net.URLEncoder; +import java.security.KeyPair; +import java.security.KeyPairGenerator; +import java.security.interfaces.RSAPublicKey; +import java.util.Base64; + +/** + * One-time setup utility for OAuth 2.1 private_key_jwt integration testing. + * + *

This utility: + *

    + *
  1. Generates a 2048-bit RSA key pair
  2. + *
  3. Writes the private key PEM to a file
  4. + *
  5. Writes a jwt-credentials.properties template
  6. + *
  7. Prints the curl command to register the public JWK on HERE Account
  8. + *
+ * + *

Usage: + *

+ *   mvn -DskipTests compile exec:java \
+ *     -Dexec.mainClass="com.here.account.oauth2.JwtTestSetupUtil" \
+ *     -Dexec.classpathScope="test" \
+ *     -Dexec.args="YOUR_APP_HRN YOUR_BEARER_TOKEN [output_dir]"
+ * 
+ * + * Or simply run with: + *
+ *   java -cp target/test-classes:target/classes com.here.account.oauth2.JwtTestSetupUtil \
+ *     hrn:here:account::HERE:app/YOUR_APP_ID \
+ *     YOUR_BEARER_TOKEN \
+ *     ~/.here
+ * 
+ */ +public class JwtTestSetupUtil { + + private static final String DEFAULT_OUTPUT_DIR = System.getProperty("user.home") + File.separator + ".here"; + private static final String DEFAULT_TOKEN_ENDPOINT = "https://account.api.here.com/oauth2/token"; + private static final String DEFAULT_HA_BASE_URL = "https://account.api.here.com"; + private static final String KID = "jwt-test-key-1"; + + public static void main(String[] args) throws Exception { + if (args.length < 2) { + System.err.println("Usage: JwtTestSetupUtil [outputDir] [haBaseUrl]"); + System.err.println(); + System.err.println(" appHRN - The HRN of your app (e.g. hrn:here:account::HERE:app/abc123)"); + System.err.println(" bearerToken - A valid HERE Access Token with manage permission on the app"); + System.err.println(" outputDir - Where to write key and credentials (default: ~/.here)"); + System.err.println(" haBaseUrl - HERE Account base URL (default: https://account.api.here.com)"); + System.exit(1); + } + + String appHrn = args[0]; + String bearerToken = args[1]; + String outputDir = args.length > 2 ? args[2] : DEFAULT_OUTPUT_DIR; + String haBaseUrl = args.length > 3 ? args[3] : DEFAULT_HA_BASE_URL; + + // Extract client_id from appHRN (last segment after "app/") + String clientId = appHrn.substring(appHrn.lastIndexOf('/') + 1); + + // 1. Generate RSA key pair + System.out.println("Generating 2048-bit RSA key pair..."); + KeyPairGenerator kpg = KeyPairGenerator.getInstance("RSA"); + kpg.initialize(2048); + KeyPair keyPair = kpg.generateKeyPair(); + + RSAPublicKey publicKey = (RSAPublicKey) keyPair.getPublic(); + String n = base64UrlEncode(toUnsignedBytes(publicKey.getModulus())); + String e = base64UrlEncode(toUnsignedBytes(publicKey.getPublicExponent())); + + // 2. Write private key PEM + File outDir = new File(outputDir); + outDir.mkdirs(); + + File privateKeyFile = new File(outDir, "jwt-test-private-key.pem"); + String pem = toPemPkcs8(keyPair.getPrivate().getEncoded()); + try (FileWriter fw = new FileWriter(privateKeyFile)) { + fw.write(pem); + } + privateKeyFile.setReadable(false, false); + privateKeyFile.setReadable(true, true); + System.out.println("Private key written to: " + privateKeyFile.getAbsolutePath()); + + // 3. Write credentials properties + File credFile = new File(outDir, "jwt-credentials.properties"); + String tokenEndpoint = haBaseUrl + "/oauth2/token"; + try (FileWriter fw = new FileWriter(credFile)) { + fw.write("here.token.endpoint.url=" + tokenEndpoint + "\n"); + fw.write("here.auth.method=private_key_jwt\n"); + fw.write("here.client.id=" + clientId + "\n"); + fw.write("here.private.key=" + privateKeyFile.getAbsolutePath() + "\n"); + fw.write("here.key.id=" + KID + "\n"); + } + credFile.setReadable(false, false); + credFile.setReadable(true, true); + System.out.println("Credentials written to: " + credFile.getAbsolutePath()); + + // 4. Print JWK registration payload and curl command + String jwkPayload = "{\n" + + " \"kty\": \"RSA\",\n" + + " \"kid\": \"" + KID + "\",\n" + + " \"alg\": \"RS256\",\n" + + " \"use\": \"sig\",\n" + + " \"n\": \"" + n + "\",\n" + + " \"e\": \"" + e + "\"\n" + + "}"; + + String appHrnEncoded = urlEncode(appHrn); + + System.out.println(); + System.out.println("=== JWK Registration Payload ==="); + System.out.println(jwkPayload); + System.out.println(); + System.out.println("=== Register JWK via curl ==="); + String compactPayload = "{\"kty\":\"RSA\",\"kid\":\"" + KID + "\",\"alg\":\"RS256\",\"use\":\"sig\",\"n\":\"" + n + "\",\"e\":\"" + e + "\"}"; + System.out.println("curl -X POST '" + haBaseUrl + "/authentication/v1.1/apps/" + appHrnEncoded + "/jwks' \\"); + System.out.println(" -H 'Authorization: Bearer " + bearerToken + "' \\"); + System.out.println(" -H 'Content-Type: application/json' \\"); + System.out.println(" -d '" + compactPayload + "'"); + System.out.println(); + System.out.println("=== After registering, run the integration test ==="); + System.out.println("mvn test -Dtest=JwtClientAssertionIT -DjwtCredentialsFile=" + credFile.getAbsolutePath()); + System.out.println(); + System.out.println("=== To clean up later ==="); + System.out.println("curl -X DELETE '" + haBaseUrl + "/authentication/v1.1/apps/" + appHrnEncoded + "/jwks/{jwkHRN}' \\"); + System.out.println(" -H 'Authorization: Bearer '"); + } + + private static String base64UrlEncode(byte[] data) { + return Base64.getUrlEncoder().withoutPadding().encodeToString(data); + } + + /** + * Convert BigInteger to unsigned byte array (strip leading zero byte if present). + */ + private static byte[] toUnsignedBytes(BigInteger bigInt) { + byte[] bytes = bigInt.toByteArray(); + if (bytes[0] == 0) { + byte[] result = new byte[bytes.length - 1]; + System.arraycopy(bytes, 1, result, 0, result.length); + return result; + } + return bytes; + } + + private static String urlEncode(String value) { + try { + return URLEncoder.encode(value, "UTF-8"); + } catch (UnsupportedEncodingException e) { + throw new RuntimeException(e); + } + } + + private static String toPemPkcs8(byte[] encoded) { + String base64 = Base64.getEncoder().encodeToString(encoded); + StringBuilder sb = new StringBuilder(); + sb.append("-----BEGIN PRIVATE KEY-----\n"); + for (int i = 0; i < base64.length(); i += 64) { + sb.append(base64, i, Math.min(i + 64, base64.length())); + sb.append('\n'); + } + sb.append("-----END PRIVATE KEY-----\n"); + return sb.toString(); + } +} From 2816708dd84043050ba280c8f288889f68998cd0 Mon Sep 17 00:00:00 2001 From: Stanislav Paltis Date: Tue, 30 Jun 2026 18:36:54 -0400 Subject: [PATCH 2/4] HPI-22073 IAM-10197 IAM-10215 Update here-aaa-java-sdk to support jwt assertions Signed-off-by: Stanislav Paltis Signed-off-by: Stanislav Paltis --- .github/workflows/test.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index b1282071..58367424 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -25,7 +25,9 @@ jobs: - name: Run AAA SDK tests run: mvn -Dhere.token.endpoint.url=https://stg.account.api.here.com/oauth2/token -Dhere.access.key.id=${{ secrets.ACCESS_KEY_ID }} -Dhere.access.key.secret=${{ secrets.ACCESS_KEY_SECRET }} clean install - name: Run JWT client assertion integration test - if: ${{ secrets.JWT_PRIVATE_KEY != '' }} + if: ${{ env.JWT_PRIVATE_KEY != '' }} + env: + JWT_PRIVATE_KEY: ${{ secrets.JWT_PRIVATE_KEY }} run: | mvn test -pl here-oauth-client -Dtest=JwtClientAssertionIT \ -Dhere.token.endpoint.url=https://stg.account.api.here.com/oauth2/token \ From b6e0f9463214b59084428dbc85422ae0a52f4e23 Mon Sep 17 00:00:00 2001 From: Stanislav Paltis Date: Tue, 30 Jun 2026 18:40:17 -0400 Subject: [PATCH 3/4] HPI-22073 IAM-10197 IAM-10215 Update here-aaa-java-sdk to support jwt assertions Signed-off-by: Stanislav Paltis Signed-off-by: Stanislav Paltis --- .../here/account/oauth2/JwtClientAssertionIT.java | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/here-oauth-client/src/test/java/com/here/account/oauth2/JwtClientAssertionIT.java b/here-oauth-client/src/test/java/com/here/account/oauth2/JwtClientAssertionIT.java index 70e37b28..0d6000fe 100644 --- a/here-oauth-client/src/test/java/com/here/account/oauth2/JwtClientAssertionIT.java +++ b/here-oauth-client/src/test/java/com/here/account/oauth2/JwtClientAssertionIT.java @@ -490,13 +490,26 @@ public void test_clientAssertion_viaIniStream() throws IOException { String privateKeyValue = props.getProperty(JwtClientAssertionProvider.PRIVATE_KEY_PROPERTY); String keyId = props.getProperty(JwtClientAssertionProvider.KEY_ID_PROPERTY); + // INI format can't handle multi-line PEM values inline. + // If the private key value is inline PEM content, write it to a temp file first. + String privateKeyRef = privateKeyValue; + File tempKeyFile = null; + if (privateKeyValue.contains("-----BEGIN") || privateKeyValue.contains("MII")) { + tempKeyFile = File.createTempFile("jwt-it-key", ".pem"); + tempKeyFile.deleteOnExit(); + try (java.io.FileWriter fw = new java.io.FileWriter(tempKeyFile)) { + fw.write(privateKeyValue); + } + privateKeyRef = tempKeyFile.getAbsolutePath(); + } + // Build INI content StringBuilder ini = new StringBuilder(); ini.append("[default]\n"); ini.append("here.token.endpoint.url = ").append(tokenUrl).append("\n"); ini.append("here.auth.method = private_key_jwt\n"); ini.append("here.client.id = ").append(clientId).append("\n"); - ini.append("here.private.key = ").append(privateKeyValue).append("\n"); + ini.append("here.private.key = ").append(privateKeyRef).append("\n"); if (keyId != null) { ini.append("here.key.id = ").append(keyId).append("\n"); } From b4d4d3e9857779cf62cfd65a087e30f7905b1390 Mon Sep 17 00:00:00 2001 From: Stanislav Paltis Date: Wed, 1 Jul 2026 10:11:10 -0400 Subject: [PATCH 4/4] HPI-22073 IAM-10197 IAM-10215 Update here-aaa-java-sdk to support jwt assertions Signed-off-by: Stanislav Paltis --- README.md | 3 + .../auth/JwtClientAssertionBuilder.java | 66 +++++++++++++++++-- .../auth/JwtClientAssertionProvider.java | 31 ++++++++- .../FromHereCredentialsIniStream.java | 3 + .../auth/JwtClientAssertionBuilderTest.java | 34 ++++++++++ .../auth/JwtClientAssertionProviderTest.java | 31 +++++++++ .../account/oauth2/JwtClientAssertionIT.java | 54 +++++++++++++++ 7 files changed, 216 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index f9e0d3b7..cf959936 100644 --- a/README.md +++ b/README.md @@ -206,6 +206,7 @@ here.auth.method=private_key_jwt here.client.id= here.private.key=/path/to/private-key.pem here.key.id= +here.signing.algorithm=RS256 ``` Usage (same API as OAuth 1.0 — auto-detected by the provider chain): @@ -235,6 +236,8 @@ try (HereAccessTokenProvider accessTokens = HereAccessTokenProvider.builder() The private key must be a PKCS#8 PEM-encoded RSA key. The `here.private.key` value can be either a file path or the inline PEM content itself (useful for environment variables in CI). +Currently only the RS256 signing algorithm is supported. PS256 and ES256 are reserved for future use. + # License Copyright (C) 2016-2019 HERE Europe B.V. diff --git a/here-oauth-client/src/main/java/com/here/account/auth/JwtClientAssertionBuilder.java b/here-oauth-client/src/main/java/com/here/account/auth/JwtClientAssertionBuilder.java index 45e373c5..70fdd0e2 100644 --- a/here-oauth-client/src/main/java/com/here/account/auth/JwtClientAssertionBuilder.java +++ b/here-oauth-client/src/main/java/com/here/account/auth/JwtClientAssertionBuilder.java @@ -45,7 +45,47 @@ */ public class JwtClientAssertionBuilder { - private static final String SIGNATURE_ALGORITHM = "SHA256withRSA"; + /** + * Supported signing algorithms and their corresponding Java Security algorithm names. + * Currently only RS256 is supported by the HERE Account server. + * PS256 and ES256 are reserved for future use. + * + * To enable PS256/ES256 support when the server adds it: + * 1. Uncomment the enum values below + * 2. Make setAlgorithm() public + * The property parsing (here.signing.algorithm), provider wiring, and builder + * logic are already in place — no other changes needed. + */ + enum SigningAlgorithm { + RS256("RS256", "SHA256withRSA"); + // PS256("PS256", "SHA256withRSAandMGF1"), // reserved for future use + // ES256("ES256", "SHA256withECDSA"); // reserved for future use + + private final String jwtName; + private final String javaName; + + SigningAlgorithm(String jwtName, String javaName) { + this.jwtName = jwtName; + this.javaName = javaName; + } + + public String getJwtName() { return jwtName; } + public String getJavaName() { return javaName; } + + /** + * Parse a signing algorithm from its JWT name (e.g. "RS256"). + * Returns null if the algorithm is not recognized or not yet supported. + */ + static SigningAlgorithm fromJwtName(String name) { + if (name == null) return null; + for (SigningAlgorithm alg : values()) { + if (alg.jwtName.equalsIgnoreCase(name)) return alg; + } + return null; + } + } + + private static final SigningAlgorithm DEFAULT_ALGORITHM = SigningAlgorithm.RS256; /** * Default token lifetime: 5 minutes (300 seconds). @@ -59,6 +99,7 @@ public class JwtClientAssertionBuilder { private final Clock clock; private long expirySeconds = DEFAULT_EXPIRY_SECONDS; private String kid; + private SigningAlgorithm algorithm = DEFAULT_ALGORITHM; /** * Construct a new JWT client assertion builder. @@ -107,6 +148,20 @@ public JwtClientAssertionBuilder setKid(String kid) { return this; } + /** + * Set the signing algorithm. Default is RS256. + * The algorithm must match what is registered on the app's JWK on the server. + * Currently only RS256 is supported. + * + * @param algorithm the signing algorithm to use + * @return this builder + */ + JwtClientAssertionBuilder setAlgorithm(SigningAlgorithm algorithm) { + Objects.requireNonNull(algorithm, "algorithm is required"); + this.algorithm = algorithm; + return this; + } + /** * Build and sign a new JWT client assertion. * @@ -141,10 +196,13 @@ public String buildAssertion() { } private String buildHeaderJson() { + StringBuilder header = new StringBuilder(); + header.append("{\"alg\":\"").append(algorithm.getJwtName()).append("\",\"typ\":\"JWT\""); if (kid != null && !kid.isEmpty()) { - return "{\"alg\":\"RS256\",\"typ\":\"JWT\",\"kid\":\"" + escapeJson(kid) + "\"}"; + header.append(",\"kid\":\"").append(escapeJson(kid)).append("\""); } - return "{\"alg\":\"RS256\",\"typ\":\"JWT\"}"; + header.append("}"); + return header.toString(); } private String buildPayloadJson(long iat, long exp, String jti) { @@ -162,7 +220,7 @@ private String buildPayloadJson(long iat, long exp, String jti) { private String sign(String signingInput) { try { - Signature sig = Signature.getInstance(SIGNATURE_ALGORITHM); + Signature sig = Signature.getInstance(algorithm.getJavaName()); sig.initSign(privateKey); sig.update(signingInput.getBytes(OAuthConstants.UTF_8_CHARSET)); byte[] signatureBytes = sig.sign(); diff --git a/here-oauth-client/src/main/java/com/here/account/auth/JwtClientAssertionProvider.java b/here-oauth-client/src/main/java/com/here/account/auth/JwtClientAssertionProvider.java index c4c58b82..61831485 100644 --- a/here-oauth-client/src/main/java/com/here/account/auth/JwtClientAssertionProvider.java +++ b/here-oauth-client/src/main/java/com/here/account/auth/JwtClientAssertionProvider.java @@ -58,6 +58,8 @@ *
  • {@value #CLIENT_ID_PROPERTY} - the client identifier
  • *
  • {@value #PRIVATE_KEY_PROPERTY} - PEM-encoded PKCS#8 RSA private key (inline or file path)
  • *
  • {@value #TOKEN_SCOPE_PROPERTY} - (optional) token scope
  • + *
  • {@value #KEY_ID_PROPERTY} - (optional) key ID matching the registered JWK
  • + *
  • {@value #SIGNING_ALGORITHM_PROPERTY} - (optional) signing algorithm, defaults to RS256. Currently only RS256 is supported.
  • * * * @see RFC 7523 §2.2 @@ -71,6 +73,7 @@ public class JwtClientAssertionProvider implements ClientAuthorizationRequestPro public static final String TOKEN_SCOPE_PROPERTY = "here.token.scope"; public static final String AUTH_METHOD_PROPERTY = "here.auth.method"; public static final String KEY_ID_PROPERTY = "here.key.id"; + public static final String SIGNING_ALGORITHM_PROPERTY = "here.signing.algorithm"; /** * The auth method value that identifies private_key_jwt authentication. @@ -83,6 +86,7 @@ public class JwtClientAssertionProvider implements ClientAuthorizationRequestPro private final PrivateKey privateKey; private final String scope; private final String kid; + private final JwtClientAssertionBuilder.SigningAlgorithm algorithm; private final NoAuthorizer noAuthorizer = new NoAuthorizer(); /** @@ -96,7 +100,7 @@ public class JwtClientAssertionProvider implements ClientAuthorizationRequestPro */ public JwtClientAssertionProvider(Clock clock, String tokenEndpointUrl, String clientId, PrivateKey privateKey, String scope) { - this(clock, tokenEndpointUrl, clientId, privateKey, scope, null); + this(clock, tokenEndpointUrl, clientId, privateKey, scope, null, null); } /** @@ -111,6 +115,23 @@ public JwtClientAssertionProvider(Clock clock, String tokenEndpointUrl, String c */ public JwtClientAssertionProvider(Clock clock, String tokenEndpointUrl, String clientId, PrivateKey privateKey, String scope, String kid) { + this(clock, tokenEndpointUrl, clientId, privateKey, scope, kid, null); + } + + /** + * Construct a new JwtClientAssertionProvider with key ID and algorithm. + * + * @param clock the clock implementation + * @param tokenEndpointUrl the token endpoint URL + * @param clientId the client identifier + * @param privateKey the RSA private key for signing assertions + * @param scope the optional token scope (may be null) + * @param kid the optional key ID to include in JWT header (may be null) + * @param algorithm the optional signing algorithm (may be null, defaults to RS256) + */ + JwtClientAssertionProvider(Clock clock, String tokenEndpointUrl, String clientId, + PrivateKey privateKey, String scope, String kid, + JwtClientAssertionBuilder.SigningAlgorithm algorithm) { Objects.requireNonNull(clock, "clock is required"); Objects.requireNonNull(tokenEndpointUrl, "tokenEndpointUrl is required"); Objects.requireNonNull(clientId, "clientId is required"); @@ -122,6 +143,7 @@ public JwtClientAssertionProvider(Clock clock, String tokenEndpointUrl, String c this.privateKey = privateKey; this.scope = scope; this.kid = kid; + this.algorithm = algorithm; } /** @@ -136,7 +158,9 @@ public JwtClientAssertionProvider(Clock clock, Properties properties) { properties.getProperty(CLIENT_ID_PROPERTY), loadPrivateKey(properties.getProperty(PRIVATE_KEY_PROPERTY)), properties.getProperty(TOKEN_SCOPE_PROPERTY), - properties.getProperty(KEY_ID_PROPERTY)); + properties.getProperty(KEY_ID_PROPERTY), + JwtClientAssertionBuilder.SigningAlgorithm.fromJwtName( + properties.getProperty(SIGNING_ALGORITHM_PROPERTY))); } /** @@ -172,6 +196,9 @@ public AccessTokenRequest getNewAccessTokenRequest() { if (kid != null && !kid.isEmpty()) { builder.setKid(kid); } + if (algorithm != null) { + builder.setAlgorithm(algorithm); + } String assertion = builder.buildAssertion(); AccessTokenRequest request = new ClientAssertionCredentialsGrantRequest(assertion); diff --git a/here-oauth-client/src/main/java/com/here/account/auth/provider/FromHereCredentialsIniStream.java b/here-oauth-client/src/main/java/com/here/account/auth/provider/FromHereCredentialsIniStream.java index bbdf5692..1c849622 100644 --- a/here-oauth-client/src/main/java/com/here/account/auth/provider/FromHereCredentialsIniStream.java +++ b/here-oauth-client/src/main/java/com/here/account/auth/provider/FromHereCredentialsIniStream.java @@ -119,6 +119,9 @@ static Properties getPropertiesFromIni(InputStream inputStream, String sectionNa case JwtClientAssertionProvider.KEY_ID_PROPERTY: properties.put(JwtClientAssertionProvider.KEY_ID_PROPERTY, value); break; + case JwtClientAssertionProvider.SIGNING_ALGORITHM_PROPERTY: + properties.put(JwtClientAssertionProvider.SIGNING_ALGORITHM_PROPERTY, value); + break; } } return properties; diff --git a/here-oauth-client/src/test/java/com/here/account/auth/JwtClientAssertionBuilderTest.java b/here-oauth-client/src/test/java/com/here/account/auth/JwtClientAssertionBuilderTest.java index 0d440698..42ac526e 100644 --- a/here-oauth-client/src/test/java/com/here/account/auth/JwtClientAssertionBuilderTest.java +++ b/here-oauth-client/src/test/java/com/here/account/auth/JwtClientAssertionBuilderTest.java @@ -172,6 +172,40 @@ public void testConstructor_rejectsNullPrivateKey() { new JwtClientAssertionBuilder(clock, CLIENT_ID, TOKEN_ENDPOINT, null); } + @Test + public void testDefaultAlgorithm_isRS256() { + JwtClientAssertionBuilder builder = new JwtClientAssertionBuilder(clock, CLIENT_ID, TOKEN_ENDPOINT, privateKey); + String jwt = builder.buildAssertion(); + String header = decodeBase64Url(jwt.split("\\.")[0]); + assertTrue("default algorithm must be RS256", header.contains("\"alg\":\"RS256\"")); + } + + @Test + public void testFromJwtName_returnsRS256() { + JwtClientAssertionBuilder.SigningAlgorithm alg = JwtClientAssertionBuilder.SigningAlgorithm.fromJwtName("RS256"); + assertNotNull(alg); + assertEquals("RS256", alg.getJwtName()); + assertEquals("SHA256withRSA", alg.getJavaName()); + } + + @Test + public void testFromJwtName_caseInsensitive() { + JwtClientAssertionBuilder.SigningAlgorithm alg = JwtClientAssertionBuilder.SigningAlgorithm.fromJwtName("rs256"); + assertNotNull(alg); + assertEquals("RS256", alg.getJwtName()); + } + + @Test + public void testFromJwtName_nullReturnsNull() { + assertNull(JwtClientAssertionBuilder.SigningAlgorithm.fromJwtName(null)); + } + + @Test + public void testFromJwtName_unknownReturnsNull() { + assertNull(JwtClientAssertionBuilder.SigningAlgorithm.fromJwtName("PS256")); + assertNull(JwtClientAssertionBuilder.SigningAlgorithm.fromJwtName("UNKNOWN")); + } + private static String decodeBase64Url(String encoded) { return new String(Base64.getUrlDecoder().decode(encoded), java.nio.charset.StandardCharsets.UTF_8); } diff --git a/here-oauth-client/src/test/java/com/here/account/auth/JwtClientAssertionProviderTest.java b/here-oauth-client/src/test/java/com/here/account/auth/JwtClientAssertionProviderTest.java index d3f389ef..03e452ee 100644 --- a/here-oauth-client/src/test/java/com/here/account/auth/JwtClientAssertionProviderTest.java +++ b/here-oauth-client/src/test/java/com/here/account/auth/JwtClientAssertionProviderTest.java @@ -133,6 +133,37 @@ public void testFromProperties() { assertTrue("header should contain kid", header.contains("\"kid\":\"kid-123\"")); } + @Test + public void testFromProperties_withExplicitAlgorithm() { + Properties props = new Properties(); + props.setProperty(JwtClientAssertionProvider.TOKEN_ENDPOINT_URL_PROPERTY, TOKEN_ENDPOINT); + props.setProperty(JwtClientAssertionProvider.CLIENT_ID_PROPERTY, CLIENT_ID); + props.setProperty(JwtClientAssertionProvider.PRIVATE_KEY_PROPERTY, pemPrivateKey); + props.setProperty(JwtClientAssertionProvider.SIGNING_ALGORITHM_PROPERTY, "RS256"); + + JwtClientAssertionProvider provider = new JwtClientAssertionProvider(clock, props); + AccessTokenRequest request = provider.getNewAccessTokenRequest(); + String jwt = request.toFormParams().get("client_assertion").get(0); + String header = new String(Base64.getUrlDecoder().decode(jwt.split("\\.")[0])); + assertTrue("header should contain RS256", header.contains("\"alg\":\"RS256\"")); + } + + @Test + public void testFromProperties_unknownAlgorithmFallsBackToDefault() { + Properties props = new Properties(); + props.setProperty(JwtClientAssertionProvider.TOKEN_ENDPOINT_URL_PROPERTY, TOKEN_ENDPOINT); + props.setProperty(JwtClientAssertionProvider.CLIENT_ID_PROPERTY, CLIENT_ID); + props.setProperty(JwtClientAssertionProvider.PRIVATE_KEY_PROPERTY, pemPrivateKey); + props.setProperty(JwtClientAssertionProvider.SIGNING_ALGORITHM_PROPERTY, "PS256"); + + // PS256 is not yet in the enum, so fromJwtName returns null, provider uses default RS256 + JwtClientAssertionProvider provider = new JwtClientAssertionProvider(clock, props); + AccessTokenRequest request = provider.getNewAccessTokenRequest(); + String jwt = request.toFormParams().get("client_assertion").get(0); + String header = new String(Base64.getUrlDecoder().decode(jwt.split("\\.")[0])); + assertTrue("should fall back to RS256", header.contains("\"alg\":\"RS256\"")); + } + @Test public void testIsPrivateKeyJwtConfigured_trueWhenAllPresent() { Properties props = new Properties(); diff --git a/here-oauth-client/src/test/java/com/here/account/oauth2/JwtClientAssertionIT.java b/here-oauth-client/src/test/java/com/here/account/oauth2/JwtClientAssertionIT.java index 0d6000fe..16a545bd 100644 --- a/here-oauth-client/src/test/java/com/here/account/oauth2/JwtClientAssertionIT.java +++ b/here-oauth-client/src/test/java/com/here/account/oauth2/JwtClientAssertionIT.java @@ -435,6 +435,60 @@ public void test_clientAssertion_replayedJwt_returns401() { } } + /** + * Negative test: JWT header contains an unsupported algorithm. + * The server only supports RS256; other values should be rejected. + * Expected: 400 - UnsupportedAppJwkAlg + */ + @Test + public void test_clientAssertion_unsupportedAlgorithm_returns400() { + String clientId = props.getProperty(JwtClientAssertionProvider.CLIENT_ID_PROPERTY); + String tokenEndpointUrl = props.getProperty(JwtClientAssertionProvider.TOKEN_ENDPOINT_URL_PROPERTY); + PrivateKey privateKey = JwtClientAssertionProvider.loadPrivateKey( + props.getProperty(JwtClientAssertionProvider.PRIVATE_KEY_PROPERTY)); + String kid = props.getProperty(JwtClientAssertionProvider.KEY_ID_PROPERTY); + + // Manually craft a JWT with alg=PS256 in the header but still sign with RSA + // (the server rejects based on the alg header value, not the actual signature algorithm) + long nowSeconds = System.currentTimeMillis() / 1000L; + String header = "{\"alg\":\"PS256\",\"typ\":\"JWT\"" + + (kid != null ? ",\"kid\":\"" + kid + "\"" : "") + "}"; + String payload = "{\"iss\":\"" + clientId + "\",\"sub\":\"" + clientId + + "\",\"aud\":\"" + tokenEndpointUrl + "\",\"exp\":" + (nowSeconds + 300) + + ",\"iat\":" + nowSeconds + ",\"jti\":\"" + java.util.UUID.randomUUID() + "\"}"; + + String headerEncoded = java.util.Base64.getUrlEncoder().withoutPadding() + .encodeToString(header.getBytes(java.nio.charset.StandardCharsets.UTF_8)); + String payloadEncoded = java.util.Base64.getUrlEncoder().withoutPadding() + .encodeToString(payload.getBytes(java.nio.charset.StandardCharsets.UTF_8)); + String signingInput = headerEncoded + "." + payloadEncoded; + + // Sign with SHA256withRSA (the key we have), but header claims PS256 + String signature; + try { + java.security.Signature sig = java.security.Signature.getInstance("SHA256withRSA"); + sig.initSign(privateKey); + sig.update(signingInput.getBytes(java.nio.charset.StandardCharsets.UTF_8)); + signature = java.util.Base64.getUrlEncoder().withoutPadding().encodeToString(sig.sign()); + } catch (Exception e) { + fail("Failed to sign: " + e.getMessage()); + return; + } + + String jwt = signingInput + "." + signature; + ClientAssertionCredentialsGrantRequest request = new ClientAssertionCredentialsGrantRequest(jwt); + + TokenEndpoint tokenEndpoint = HereAccount.getTokenEndpoint(httpProvider, provider); + try { + tokenEndpoint.requestToken(request); + fail("Should have thrown AccessTokenException for unsupported algorithm"); + } catch (AccessTokenException e) { + // 400454 = UnsupportedAppJwkAlg (alg not in server's supported list) + assertEquals("expected 400 status", 400, e.getStatusCode()); + assertEquals("expected errorCode 400454", Integer.valueOf(400454), e.getErrorResponse().getErrorCode()); + } + } + /** * Test: HereAccessTokenProvider.builder().build() picks up private_key_jwt * from system properties via the ClientAuthorizationProviderChain.