Skip to content

Commit 46b39e7

Browse files
committed
HPI-22073 IAM-10197 IAM-10215 Update here-aaa-java-sdk to support jwt assertions
1 parent fecf4e4 commit 46b39e7

13 files changed

Lines changed: 2055 additions & 3 deletions

.github/workflows/test.yml

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,4 +23,14 @@ 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: ${{ secrets.JWT_PRIVATE_KEY != '' }}
29+
run: |
30+
mvn test -pl here-oauth-client -Dtest=JwtClientAssertionIT \
31+
-Dhere.token.endpoint.url=https://stg.account.api.here.com/oauth2/token \
32+
-Dhere.jwt.client.id=${{ secrets.JWT_CLIENT_ID }} \
33+
-Dhere.jwt.private.key="${{ secrets.JWT_PRIVATE_KEY }}" \
34+
-Dhere.jwt.key.id=${{ secrets.JWT_KEY_ID }} \
35+
-Dhere.jwt.token.scope=${{ secrets.JWT_TOKEN_SCOPE }}
36+

.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: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -192,6 +192,49 @@ 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+
```
210+
211+
Usage (same API as OAuth 1.0 — auto-detected by the provider chain):
212+
```java
213+
try (HereAccessTokenProvider accessTokens = HereAccessTokenProvider.builder().build()) {
214+
String accessToken = accessTokens.getAccessToken();
215+
}
216+
```
217+
218+
Or construct the provider explicitly:
219+
```java
220+
JwtClientAssertionProvider provider = new JwtClientAssertionProvider(
221+
new SettableSystemClock(),
222+
"https://account.api.here.com/oauth2/token",
223+
"<your_client_id>",
224+
JwtClientAssertionProvider.loadPrivateKey("/path/to/private-key.pem"),
225+
null, // scope (optional)
226+
"<kid>" // key ID (optional)
227+
);
228+
try (HereAccessTokenProvider accessTokens = HereAccessTokenProvider.builder()
229+
.setClientAuthorizationRequestProvider(provider)
230+
.build()) {
231+
String accessToken = accessTokens.getAccessToken();
232+
}
233+
```
234+
235+
The private key must be a PKCS#8 PEM-encoded RSA key. The `here.private.key` value can be either
236+
a file path or the inline PEM content itself (useful for environment variables in CI).
237+
195238
# License
196239

197240
Copyright (C) 2016-2019 HERE Europe B.V.
Lines changed: 209 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,209 @@
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+
private static final String SIGNATURE_ALGORITHM = "SHA256withRSA";
49+
50+
/**
51+
* Default token lifetime: 5 minutes (300 seconds).
52+
* Per RFC 7523 §3, the assertion SHOULD have a short lifetime.
53+
*/
54+
private static final long DEFAULT_EXPIRY_SECONDS = 300L;
55+
56+
private final String clientId;
57+
private final String tokenEndpointUrl;
58+
private final PrivateKey privateKey;
59+
private final Clock clock;
60+
private long expirySeconds = DEFAULT_EXPIRY_SECONDS;
61+
private String kid;
62+
63+
/**
64+
* Construct a new JWT client assertion builder.
65+
*
66+
* @param clock the clock for timestamps
67+
* @param clientId the client identifier (becomes iss and sub)
68+
* @param tokenEndpointUrl the token endpoint URL (becomes aud)
69+
* @param privateKey the RSA private key for signing
70+
*/
71+
public JwtClientAssertionBuilder(Clock clock, String clientId, String tokenEndpointUrl, PrivateKey privateKey) {
72+
Objects.requireNonNull(clock, "clock is required");
73+
Objects.requireNonNull(clientId, "clientId is required");
74+
Objects.requireNonNull(tokenEndpointUrl, "tokenEndpointUrl is required");
75+
Objects.requireNonNull(privateKey, "privateKey is required");
76+
77+
this.clock = clock;
78+
this.clientId = clientId;
79+
this.tokenEndpointUrl = tokenEndpointUrl;
80+
this.privateKey = privateKey;
81+
}
82+
83+
/**
84+
* Set a custom expiry duration for the assertion JWT.
85+
*
86+
* @param expirySeconds lifetime in seconds (must be positive, max recommended 300s)
87+
* @return this builder
88+
*/
89+
public JwtClientAssertionBuilder setExpirySeconds(long expirySeconds) {
90+
if (expirySeconds <= 0) {
91+
throw new IllegalArgumentException("expirySeconds must be positive");
92+
}
93+
this.expirySeconds = expirySeconds;
94+
return this;
95+
}
96+
97+
/**
98+
* Set the Key ID (kid) to include in the JWT header.
99+
* When the application has multiple registered JWKs, the kid helps the
100+
* authorization server identify which public key to use for verification.
101+
*
102+
* @param kid the key identifier (matches the kid registered on the app's JWK)
103+
* @return this builder
104+
*/
105+
public JwtClientAssertionBuilder setKid(String kid) {
106+
this.kid = kid;
107+
return this;
108+
}
109+
110+
/**
111+
* Build and sign a new JWT client assertion.
112+
*
113+
* <p>Per RFC 7523 §3, the JWT MUST contain:
114+
* <ul>
115+
* <li>iss - the client_id</li>
116+
* <li>sub - the client_id</li>
117+
* <li>aud - the token endpoint URL</li>
118+
* <li>exp - expiration time</li>
119+
* <li>iat - issued at time</li>
120+
* <li>jti - unique token identifier (prevents replay)</li>
121+
* </ul>
122+
*
123+
* @return the compact serialized JWT (header.payload.signature)
124+
* @throws ClientAssertionException if signing fails
125+
*/
126+
public String buildAssertion() {
127+
long nowSeconds = clock.currentTimeMillis() / 1000L;
128+
long exp = nowSeconds + expirySeconds;
129+
String jti = UUID.randomUUID().toString();
130+
131+
String header = buildHeaderJson();
132+
String payload = buildPayloadJson(nowSeconds, exp, jti);
133+
134+
String headerEncoded = base64UrlEncode(header.getBytes(OAuthConstants.UTF_8_CHARSET));
135+
String payloadEncoded = base64UrlEncode(payload.getBytes(OAuthConstants.UTF_8_CHARSET));
136+
137+
String signingInput = headerEncoded + "." + payloadEncoded;
138+
String signature = sign(signingInput);
139+
140+
return signingInput + "." + signature;
141+
}
142+
143+
private String buildHeaderJson() {
144+
if (kid != null && !kid.isEmpty()) {
145+
return "{\"alg\":\"RS256\",\"typ\":\"JWT\",\"kid\":\"" + escapeJson(kid) + "\"}";
146+
}
147+
return "{\"alg\":\"RS256\",\"typ\":\"JWT\"}";
148+
}
149+
150+
private String buildPayloadJson(long iat, long exp, String jti) {
151+
// Manual JSON construction to avoid dependency on serialization library for this simple case.
152+
// All string values are safe (no special chars that need escaping in client_id, URL, UUID).
153+
return "{" +
154+
"\"iss\":\"" + escapeJson(clientId) + "\"," +
155+
"\"sub\":\"" + escapeJson(clientId) + "\"," +
156+
"\"aud\":\"" + escapeJson(tokenEndpointUrl) + "\"," +
157+
"\"exp\":" + exp + "," +
158+
"\"iat\":" + iat + "," +
159+
"\"jti\":\"" + escapeJson(jti) + "\"" +
160+
"}";
161+
}
162+
163+
private String sign(String signingInput) {
164+
try {
165+
Signature sig = Signature.getInstance(SIGNATURE_ALGORITHM);
166+
sig.initSign(privateKey);
167+
sig.update(signingInput.getBytes(OAuthConstants.UTF_8_CHARSET));
168+
byte[] signatureBytes = sig.sign();
169+
return base64UrlEncode(signatureBytes);
170+
} catch (NoSuchAlgorithmException | InvalidKeyException | SignatureException e) {
171+
throw new ClientAssertionException("Failed to sign JWT client assertion: " + e.getMessage(), e);
172+
}
173+
}
174+
175+
private static String base64UrlEncode(byte[] data) {
176+
return Base64.getUrlEncoder().withoutPadding().encodeToString(data);
177+
}
178+
179+
/**
180+
* Minimal JSON string escaping for known-safe values (client IDs, URLs, UUIDs).
181+
*/
182+
private static String escapeJson(String value) {
183+
if (value == null) {
184+
return "";
185+
}
186+
StringBuilder sb = new StringBuilder(value.length());
187+
for (int i = 0; i < value.length(); i++) {
188+
char c = value.charAt(i);
189+
switch (c) {
190+
case '"': sb.append("\\\""); break;
191+
case '\\': sb.append("\\\\"); break;
192+
case '\n': sb.append("\\n"); break;
193+
case '\r': sb.append("\\r"); break;
194+
case '\t': sb.append("\\t"); break;
195+
default: sb.append(c);
196+
}
197+
}
198+
return sb.toString();
199+
}
200+
201+
/**
202+
* Exception thrown when JWT client assertion building/signing fails.
203+
*/
204+
public static class ClientAssertionException extends RuntimeException {
205+
public ClientAssertionException(String message, Throwable cause) {
206+
super(message, cause);
207+
}
208+
}
209+
}

0 commit comments

Comments
 (0)