Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 13 additions & 1 deletion .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -23,4 +23,16 @@ 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
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: ${{ 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 \
-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 }}

7 changes: 7 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
46 changes: 46 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,52 @@ 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(<httpProvider>).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=<your_client_id>
here.private.key=/path/to/private-key.pem
here.key.id=<optional_kid>
here.signing.algorithm=RS256
```

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",
"<your_client_id>",
JwtClientAssertionProvider.loadPrivateKey("/path/to/private-key.pem"),
null, // scope (optional)
"<kid>" // 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).

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.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,267 @@
/*
* 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.
*
* <p>
* The produced JWT has:
* <ul>
* <li>Header: {@code {"alg":"RS256","typ":"JWT"}}</li>
* <li>Claims: iss, sub (both = client_id), aud (token endpoint), exp, iat, jti</li>
* <li>Signature: RS256 (RSASSA-PKCS1-v1_5 with SHA-256)</li>
* </ul>
*
* @see <a href="https://www.rfc-editor.org/rfc/rfc7523#section-2.2">RFC 7523 §2.2</a>
* @see <a href="https://www.rfc-editor.org/rfc/rfc7519">RFC 7519 (JWT)</a>
* @see <a href="https://datatracker.ietf.org/doc/html/draft-ietf-oauth-v2-1-13">OAuth 2.1</a>
*/
public class JwtClientAssertionBuilder {

/**
* 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).
* 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;
private SigningAlgorithm algorithm = DEFAULT_ALGORITHM;

/**
* 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;
}

/**
* 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.
*
* <p>Per RFC 7523 §3, the JWT MUST contain:
* <ul>
* <li>iss - the client_id</li>
* <li>sub - the client_id</li>
* <li>aud - the token endpoint URL</li>
* <li>exp - expiration time</li>
* <li>iat - issued at time</li>
* <li>jti - unique token identifier (prevents replay)</li>
* </ul>
*
* @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() {
StringBuilder header = new StringBuilder();
header.append("{\"alg\":\"").append(algorithm.getJwtName()).append("\",\"typ\":\"JWT\"");
if (kid != null && !kid.isEmpty()) {
header.append(",\"kid\":\"").append(escapeJson(kid)).append("\"");
}
header.append("}");
return header.toString();
}

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(algorithm.getJavaName());
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);
}
}
}
Loading
Loading