Skip to content

Commit b447a5a

Browse files
authored
Merge pull request #164 from heremaps/IAM-10215
HPI-22073 IAM-10197 IAM-10215 Update here-aaa-java-sdk to support jwt…
2 parents fecf4e4 + b4d4d3e commit b447a5a

13 files changed

Lines changed: 2280 additions & 3 deletions

.github/workflows/test.yml

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,4 +23,16 @@ jobs:
2323
- name: Verify Maven installation
2424
run: mvn -v
2525
- name: Run AAA SDK tests
26-
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
26+
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
27+
- name: Run JWT client assertion integration test
28+
if: ${{ env.JWT_PRIVATE_KEY != '' }}
29+
env:
30+
JWT_PRIVATE_KEY: ${{ secrets.JWT_PRIVATE_KEY }}
31+
run: |
32+
mvn test -pl here-oauth-client -Dtest=JwtClientAssertionIT \
33+
-Dhere.token.endpoint.url=https://stg.account.api.here.com/oauth2/token \
34+
-Dhere.jwt.client.id=${{ secrets.JWT_CLIENT_ID }} \
35+
-Dhere.jwt.private.key="${{ secrets.JWT_PRIVATE_KEY }}" \
36+
-Dhere.jwt.key.id=${{ secrets.JWT_KEY_ID }} \
37+
-Dhere.jwt.token.scope=${{ secrets.JWT_TOKEN_SCOPE }}
38+

.gitignore

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,3 +10,10 @@ target
1010
/bin/
1111
pom.xml.versionsBackup
1212
examples/here-oauth-client-example/dependency-reduced-pom.xml
13+
14+
# Credentials / secrets — never commit
15+
*.credentials.properties
16+
credentials.properties
17+
credentials.ini
18+
*.pem
19+
*.key

README.md

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -192,6 +192,52 @@ You can also use one of the proxy options to getAccessToken
192192

193193
If you want move advanced options, you can provide your own HttpProvider `HereAccessTokenProvider.builder().setHttpProvider(<httpProvider>).build();`
194194

195+
OAuth 2.1 private_key_jwt Authentication
196+
----------------------------------------
197+
198+
A fourth option uses OAuth 2.1 JWT client assertions (`private_key_jwt` per RFC 7523 §2.2) instead of OAuth 1.0 signatures.
199+
The client authenticates by signing a short-lived JWT with its RSA private key. The corresponding public key must be
200+
registered on the HERE Account server as a JWK.
201+
202+
Credentials file (`~/.here/credentials.properties` or `~/.here/credentials.ini`):
203+
```properties
204+
here.token.endpoint.url=https://account.api.here.com/oauth2/token
205+
here.auth.method=private_key_jwt
206+
here.client.id=<your_client_id>
207+
here.private.key=/path/to/private-key.pem
208+
here.key.id=<optional_kid>
209+
here.signing.algorithm=RS256
210+
```
211+
212+
Usage (same API as OAuth 1.0 — auto-detected by the provider chain):
213+
```java
214+
try (HereAccessTokenProvider accessTokens = HereAccessTokenProvider.builder().build()) {
215+
String accessToken = accessTokens.getAccessToken();
216+
}
217+
```
218+
219+
Or construct the provider explicitly:
220+
```java
221+
JwtClientAssertionProvider provider = new JwtClientAssertionProvider(
222+
new SettableSystemClock(),
223+
"https://account.api.here.com/oauth2/token",
224+
"<your_client_id>",
225+
JwtClientAssertionProvider.loadPrivateKey("/path/to/private-key.pem"),
226+
null, // scope (optional)
227+
"<kid>" // key ID (optional)
228+
);
229+
try (HereAccessTokenProvider accessTokens = HereAccessTokenProvider.builder()
230+
.setClientAuthorizationRequestProvider(provider)
231+
.build()) {
232+
String accessToken = accessTokens.getAccessToken();
233+
}
234+
```
235+
236+
The private key must be a PKCS#8 PEM-encoded RSA key. The `here.private.key` value can be either
237+
a file path or the inline PEM content itself (useful for environment variables in CI).
238+
239+
Currently only the RS256 signing algorithm is supported. PS256 and ES256 are reserved for future use.
240+
195241
# License
196242

197243
Copyright (C) 2016-2019 HERE Europe B.V.
Lines changed: 267 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,267 @@
1+
/*
2+
* Copyright (c) 2025 HERE Europe B.V.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
package com.here.account.auth;
17+
18+
import com.here.account.util.Clock;
19+
import com.here.account.util.OAuthConstants;
20+
21+
import java.security.InvalidKeyException;
22+
import java.security.NoSuchAlgorithmException;
23+
import java.security.PrivateKey;
24+
import java.security.Signature;
25+
import java.security.SignatureException;
26+
import java.util.Base64;
27+
import java.util.Objects;
28+
import java.util.UUID;
29+
30+
/**
31+
* Builds and signs JWT client assertions for use with OAuth 2.1 private_key_jwt
32+
* authentication per RFC 7523 Section 2.2.
33+
*
34+
* <p>
35+
* The produced JWT has:
36+
* <ul>
37+
* <li>Header: {@code {"alg":"RS256","typ":"JWT"}}</li>
38+
* <li>Claims: iss, sub (both = client_id), aud (token endpoint), exp, iat, jti</li>
39+
* <li>Signature: RS256 (RSASSA-PKCS1-v1_5 with SHA-256)</li>
40+
* </ul>
41+
*
42+
* @see <a href="https://www.rfc-editor.org/rfc/rfc7523#section-2.2">RFC 7523 §2.2</a>
43+
* @see <a href="https://www.rfc-editor.org/rfc/rfc7519">RFC 7519 (JWT)</a>
44+
* @see <a href="https://datatracker.ietf.org/doc/html/draft-ietf-oauth-v2-1-13">OAuth 2.1</a>
45+
*/
46+
public class JwtClientAssertionBuilder {
47+
48+
/**
49+
* Supported signing algorithms and their corresponding Java Security algorithm names.
50+
* Currently only RS256 is supported by the HERE Account server.
51+
* PS256 and ES256 are reserved for future use.
52+
*
53+
* To enable PS256/ES256 support when the server adds it:
54+
* 1. Uncomment the enum values below
55+
* 2. Make setAlgorithm() public
56+
* The property parsing (here.signing.algorithm), provider wiring, and builder
57+
* logic are already in place — no other changes needed.
58+
*/
59+
enum SigningAlgorithm {
60+
RS256("RS256", "SHA256withRSA");
61+
// PS256("PS256", "SHA256withRSAandMGF1"), // reserved for future use
62+
// ES256("ES256", "SHA256withECDSA"); // reserved for future use
63+
64+
private final String jwtName;
65+
private final String javaName;
66+
67+
SigningAlgorithm(String jwtName, String javaName) {
68+
this.jwtName = jwtName;
69+
this.javaName = javaName;
70+
}
71+
72+
public String getJwtName() { return jwtName; }
73+
public String getJavaName() { return javaName; }
74+
75+
/**
76+
* Parse a signing algorithm from its JWT name (e.g. "RS256").
77+
* Returns null if the algorithm is not recognized or not yet supported.
78+
*/
79+
static SigningAlgorithm fromJwtName(String name) {
80+
if (name == null) return null;
81+
for (SigningAlgorithm alg : values()) {
82+
if (alg.jwtName.equalsIgnoreCase(name)) return alg;
83+
}
84+
return null;
85+
}
86+
}
87+
88+
private static final SigningAlgorithm DEFAULT_ALGORITHM = SigningAlgorithm.RS256;
89+
90+
/**
91+
* Default token lifetime: 5 minutes (300 seconds).
92+
* Per RFC 7523 §3, the assertion SHOULD have a short lifetime.
93+
*/
94+
private static final long DEFAULT_EXPIRY_SECONDS = 300L;
95+
96+
private final String clientId;
97+
private final String tokenEndpointUrl;
98+
private final PrivateKey privateKey;
99+
private final Clock clock;
100+
private long expirySeconds = DEFAULT_EXPIRY_SECONDS;
101+
private String kid;
102+
private SigningAlgorithm algorithm = DEFAULT_ALGORITHM;
103+
104+
/**
105+
* Construct a new JWT client assertion builder.
106+
*
107+
* @param clock the clock for timestamps
108+
* @param clientId the client identifier (becomes iss and sub)
109+
* @param tokenEndpointUrl the token endpoint URL (becomes aud)
110+
* @param privateKey the RSA private key for signing
111+
*/
112+
public JwtClientAssertionBuilder(Clock clock, String clientId, String tokenEndpointUrl, PrivateKey privateKey) {
113+
Objects.requireNonNull(clock, "clock is required");
114+
Objects.requireNonNull(clientId, "clientId is required");
115+
Objects.requireNonNull(tokenEndpointUrl, "tokenEndpointUrl is required");
116+
Objects.requireNonNull(privateKey, "privateKey is required");
117+
118+
this.clock = clock;
119+
this.clientId = clientId;
120+
this.tokenEndpointUrl = tokenEndpointUrl;
121+
this.privateKey = privateKey;
122+
}
123+
124+
/**
125+
* Set a custom expiry duration for the assertion JWT.
126+
*
127+
* @param expirySeconds lifetime in seconds (must be positive, max recommended 300s)
128+
* @return this builder
129+
*/
130+
public JwtClientAssertionBuilder setExpirySeconds(long expirySeconds) {
131+
if (expirySeconds <= 0) {
132+
throw new IllegalArgumentException("expirySeconds must be positive");
133+
}
134+
this.expirySeconds = expirySeconds;
135+
return this;
136+
}
137+
138+
/**
139+
* Set the Key ID (kid) to include in the JWT header.
140+
* When the application has multiple registered JWKs, the kid helps the
141+
* authorization server identify which public key to use for verification.
142+
*
143+
* @param kid the key identifier (matches the kid registered on the app's JWK)
144+
* @return this builder
145+
*/
146+
public JwtClientAssertionBuilder setKid(String kid) {
147+
this.kid = kid;
148+
return this;
149+
}
150+
151+
/**
152+
* Set the signing algorithm. Default is RS256.
153+
* The algorithm must match what is registered on the app's JWK on the server.
154+
* Currently only RS256 is supported.
155+
*
156+
* @param algorithm the signing algorithm to use
157+
* @return this builder
158+
*/
159+
JwtClientAssertionBuilder setAlgorithm(SigningAlgorithm algorithm) {
160+
Objects.requireNonNull(algorithm, "algorithm is required");
161+
this.algorithm = algorithm;
162+
return this;
163+
}
164+
165+
/**
166+
* Build and sign a new JWT client assertion.
167+
*
168+
* <p>Per RFC 7523 §3, the JWT MUST contain:
169+
* <ul>
170+
* <li>iss - the client_id</li>
171+
* <li>sub - the client_id</li>
172+
* <li>aud - the token endpoint URL</li>
173+
* <li>exp - expiration time</li>
174+
* <li>iat - issued at time</li>
175+
* <li>jti - unique token identifier (prevents replay)</li>
176+
* </ul>
177+
*
178+
* @return the compact serialized JWT (header.payload.signature)
179+
* @throws ClientAssertionException if signing fails
180+
*/
181+
public String buildAssertion() {
182+
long nowSeconds = clock.currentTimeMillis() / 1000L;
183+
long exp = nowSeconds + expirySeconds;
184+
String jti = UUID.randomUUID().toString();
185+
186+
String header = buildHeaderJson();
187+
String payload = buildPayloadJson(nowSeconds, exp, jti);
188+
189+
String headerEncoded = base64UrlEncode(header.getBytes(OAuthConstants.UTF_8_CHARSET));
190+
String payloadEncoded = base64UrlEncode(payload.getBytes(OAuthConstants.UTF_8_CHARSET));
191+
192+
String signingInput = headerEncoded + "." + payloadEncoded;
193+
String signature = sign(signingInput);
194+
195+
return signingInput + "." + signature;
196+
}
197+
198+
private String buildHeaderJson() {
199+
StringBuilder header = new StringBuilder();
200+
header.append("{\"alg\":\"").append(algorithm.getJwtName()).append("\",\"typ\":\"JWT\"");
201+
if (kid != null && !kid.isEmpty()) {
202+
header.append(",\"kid\":\"").append(escapeJson(kid)).append("\"");
203+
}
204+
header.append("}");
205+
return header.toString();
206+
}
207+
208+
private String buildPayloadJson(long iat, long exp, String jti) {
209+
// Manual JSON construction to avoid dependency on serialization library for this simple case.
210+
// All string values are safe (no special chars that need escaping in client_id, URL, UUID).
211+
return "{" +
212+
"\"iss\":\"" + escapeJson(clientId) + "\"," +
213+
"\"sub\":\"" + escapeJson(clientId) + "\"," +
214+
"\"aud\":\"" + escapeJson(tokenEndpointUrl) + "\"," +
215+
"\"exp\":" + exp + "," +
216+
"\"iat\":" + iat + "," +
217+
"\"jti\":\"" + escapeJson(jti) + "\"" +
218+
"}";
219+
}
220+
221+
private String sign(String signingInput) {
222+
try {
223+
Signature sig = Signature.getInstance(algorithm.getJavaName());
224+
sig.initSign(privateKey);
225+
sig.update(signingInput.getBytes(OAuthConstants.UTF_8_CHARSET));
226+
byte[] signatureBytes = sig.sign();
227+
return base64UrlEncode(signatureBytes);
228+
} catch (NoSuchAlgorithmException | InvalidKeyException | SignatureException e) {
229+
throw new ClientAssertionException("Failed to sign JWT client assertion: " + e.getMessage(), e);
230+
}
231+
}
232+
233+
private static String base64UrlEncode(byte[] data) {
234+
return Base64.getUrlEncoder().withoutPadding().encodeToString(data);
235+
}
236+
237+
/**
238+
* Minimal JSON string escaping for known-safe values (client IDs, URLs, UUIDs).
239+
*/
240+
private static String escapeJson(String value) {
241+
if (value == null) {
242+
return "";
243+
}
244+
StringBuilder sb = new StringBuilder(value.length());
245+
for (int i = 0; i < value.length(); i++) {
246+
char c = value.charAt(i);
247+
switch (c) {
248+
case '"': sb.append("\\\""); break;
249+
case '\\': sb.append("\\\\"); break;
250+
case '\n': sb.append("\\n"); break;
251+
case '\r': sb.append("\\r"); break;
252+
case '\t': sb.append("\\t"); break;
253+
default: sb.append(c);
254+
}
255+
}
256+
return sb.toString();
257+
}
258+
259+
/**
260+
* Exception thrown when JWT client assertion building/signing fails.
261+
*/
262+
public static class ClientAssertionException extends RuntimeException {
263+
public ClientAssertionException(String message, Throwable cause) {
264+
super(message, cause);
265+
}
266+
}
267+
}

0 commit comments

Comments
 (0)