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 61831485..36e36236 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 @@ -187,6 +187,23 @@ public HttpProvider.HttpRequestAuthorizer getClientAuthorizer() { * Builds a new {@link ClientAssertionCredentialsGrantRequest} with a freshly * signed JWT assertion. * + *

+ * {@code scope} is baked into this provider at construction time because it is a + * configuration-time concern (which project this provider is configured for). + * + *

+ * {@code resource} (RFC 8707) is intentionally NOT set here. It is a per-request + * parameter identifying the target resource server for which the token is intended. + * RFC 8707 anticipates callers varying {@code resource} per call, so it belongs on + * the request, not the provider. Callers should set it on the returned request: + *

+     * {@code
+     * AccessTokenRequest req = provider.getNewAccessTokenRequest();
+     * req.setResource(Arrays.asList("https://api.example.com"));
+     * tokenEndpoint.requestToken(req);
+     * }
+     * 
+ * * {@inheritDoc} */ @Override diff --git a/here-oauth-client/src/main/java/com/here/account/http/apache/ApacheHttpClientProvider.java b/here-oauth-client/src/main/java/com/here/account/http/apache/ApacheHttpClientProvider.java index 88521d05..140e431c 100644 --- a/here-oauth-client/src/main/java/com/here/account/http/apache/ApacheHttpClientProvider.java +++ b/here-oauth-client/src/main/java/com/here/account/http/apache/ApacheHttpClientProvider.java @@ -343,10 +343,12 @@ private void addApacheRequestEntity(HttpRequestBase apacheRequest, String key = entry.getKey(); List valueList = entry.getValue(); if (null != key && null != valueList && valueList.size() > 0) { - String value = valueList.get(0); - if (null != value) { - NameValuePair nameValuePair = new BasicNameValuePair(key, value); - parameters.add(nameValuePair); + // A key may have multiple values to support repeated form parameters + // (e.g. RFC 8707 resource=uri1&resource=uri2). + for (String value : valueList) { + if (null != value) { + parameters.add(new BasicNameValuePair(key, value)); + } } } } diff --git a/here-oauth-client/src/main/java/com/here/account/oauth2/AccessTokenRequest.java b/here-oauth-client/src/main/java/com/here/account/oauth2/AccessTokenRequest.java index e75f5195..c2ea48be 100644 --- a/here-oauth-client/src/main/java/com/here/account/oauth2/AccessTokenRequest.java +++ b/here-oauth-client/src/main/java/com/here/account/oauth2/AccessTokenRequest.java @@ -15,6 +15,7 @@ */ package com.here.account.oauth2; +import java.util.ArrayList; import java.util.Collections; import java.util.HashMap; import java.util.List; @@ -57,6 +58,13 @@ public abstract class AccessTokenRequest implements OlpHttpMessage { */ protected static final String SCOPE_JSON = "scope"; protected static final String SCOPE_FORM = "scope"; + + /** + * resource; the parameter name for RFC 8707 resource indicators when conveyed in a form body. + * No JSON equivalent — the SDK always sends token requests as application/x-www-form-urlencoded, + * which also aligns with RFC 8707 §2. + */ + protected static final String RESOURCE_FORM = "resource"; private final String grantType; @@ -70,6 +78,7 @@ public abstract class AccessTokenRequest implements OlpHttpMessage { private Long expiresIn; private String scope; + private List resource; private transient Map additionalHeaders = null; private transient String correlationId = null; @@ -207,15 +216,40 @@ public String toJson() { } /** - * Converts this request, to its formParams Map representation. - * - * @return the formParams, for use with application/x-www-form-urlencoded bodies. + * Get the RFC 8707 resource indicator URIs for this token request. + * + * @return the list of resource URIs, or null if not set */ + public List getResource() { + return resource; + } + + /** + * Set the RFC 8707 resource indicator URIs for this token request. + * Each value must be an absolute URI per RFC 8707 §2. Query components + * SHOULD NOT be included; fragments MUST NOT be included. + * Sent as repeated {@code resource=} form parameters in the + * {@code application/x-www-form-urlencoded} POST body — not as URL query parameters. + * + *

No client-side URI validation is performed. Validation is the authorization + * server's responsibility per RFC 8707 §2; client-side checks would duplicate + * server logic and risk divergence. + * + * @param resource list of absolute resource URIs + * @return this + * @see RFC 8707 + */ + public AccessTokenRequest setResource(List resource) { + this.resource = resource; + return this; + } + public Map> toFormParams() { Map> formParams = new HashMap>(); addFormParam(formParams, GRANT_TYPE_FORM, getGrantType()); addFormParam(formParams, EXPIRES_IN_FORM, getExpiresIn()); addFormParam(formParams, SCOPE_FORM, getScope()); + addFormParams(formParams, RESOURCE_FORM, getResource()); return formParams; } @@ -233,4 +267,19 @@ protected final static void addFormParam(Map> formParams, S formParams.put(name, Collections.singletonList(value.toString())); } } + + /** + * Adds a multi-value form parameter entry to the given map. + * A defensive copy of {@code values} is stored. + * No-op if any of {@code formParams}, {@code name}, or {@code values} is null or empty. + * + * @param formParams the map to populate; must not be null + * @param name the parameter name; must not be null + * @param values the parameter values; must not be null or empty + */ + protected final static void addFormParams(Map> formParams, String name, List values) { + if (null != formParams && null != name && null != values && !values.isEmpty()) { + formParams.put(name, new ArrayList<>(values)); + } + } } 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 index 5d98943a..5d97fa53 100644 --- 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 @@ -82,4 +82,13 @@ public Map> toFormParams() { addFormParam(formParams, CLIENT_ASSERTION_FORM, clientAssertion); return formParams; } + + /** + * {@inheritDoc} + */ + @Override + public ClientAssertionCredentialsGrantRequest setResource(List resource) { + super.setResource(resource); + return this; + } } diff --git a/here-oauth-client/src/main/java/com/here/account/oauth2/ClientCredentialsGrantRequest.java b/here-oauth-client/src/main/java/com/here/account/oauth2/ClientCredentialsGrantRequest.java index 6a3c904b..551b980b 100644 --- a/here-oauth-client/src/main/java/com/here/account/oauth2/ClientCredentialsGrantRequest.java +++ b/here-oauth-client/src/main/java/com/here/account/oauth2/ClientCredentialsGrantRequest.java @@ -15,6 +15,8 @@ */ package com.here.account.oauth2; +import java.util.List; + /** * An {@link AccessTokenRequest} for grant_type=client_credentials. * @@ -37,5 +39,14 @@ public ClientCredentialsGrantRequest setExpiresIn(Long expiresIn) { super.setExpiresIn(expiresIn); return this; } + + /** + * {@inheritDoc} + */ + @Override + public ClientCredentialsGrantRequest setResource(List resource) { + super.setResource(resource); + return this; + } } diff --git a/here-oauth-client/src/main/java/com/here/account/oauth2/HereAccount.java b/here-oauth-client/src/main/java/com/here/account/oauth2/HereAccount.java index 63ff8b88..0f4f92f3 100644 --- a/here-oauth-client/src/main/java/com/here/account/oauth2/HereAccount.java +++ b/here-oauth-client/src/main/java/com/here/account/oauth2/HereAccount.java @@ -570,7 +570,9 @@ public Fresh requestAutoRefreshingToken(AccessTokenRequest return requestAutoRefreshingToken(() -> { return new ClientCredentialsGrantRequest() .setExpiresIn(request.getExpiresIn()) - .setScope(request.getScope()); + .setScope(request.getScope()) + // null if not set; addFormParams() no-op for null + .setResource(request.getResource()); }); } diff --git a/here-oauth-client/src/test/java/com/here/account/http/apache/ApacheHttpClientProviderTest.java b/here-oauth-client/src/test/java/com/here/account/http/apache/ApacheHttpClientProviderTest.java index dd8291bc..958f9ec9 100644 --- a/here-oauth-client/src/test/java/com/here/account/http/apache/ApacheHttpClientProviderTest.java +++ b/here-oauth-client/src/test/java/com/here/account/http/apache/ApacheHttpClientProviderTest.java @@ -256,6 +256,28 @@ public void test_formParamsPut_null() throws NoSuchFieldException, SecurityExcep assertTrue("httpEntity was expected null, actual "+httpEntity, null == httpEntity); } + @Test + public void test_formParams_multiValue_repeatedParams() throws Exception { + formParams = new HashMap>(); + formParams.put("grant_type", Collections.singletonList("client_credentials")); + formParams.put("resource", Arrays.asList("https://api.example.com", "https://data.example.com")); + httpRequest = httpProvider.getRequest(httpRequestAuthorizer, "POST", url, formParams); + HttpRequestBase httpRequestBase = getHttpRequestBase(); + HttpPost httpPost = (HttpPost) httpRequestBase; + HttpEntity httpEntity = httpPost.getEntity(); + assertNotNull("httpEntity was null", httpEntity); + String body = org.apache.http.util.EntityUtils.toString(httpEntity); + long resourceCount = 0; + for (String part : body.split("&")) { + if (part.startsWith("resource=")) resourceCount++; + } + assertEquals("expected 2 resource= params in: " + body, 2, resourceCount); + assertTrue("body should contain resource=https%3A%2F%2Fapi.example.com: " + body, + body.contains("resource=https%3A%2F%2Fapi.example.com")); + assertTrue("body should contain resource=https%3A%2F%2Fdata.example.com: " + body, + body.contains("resource=https%3A%2F%2Fdata.example.com")); + } + @Test public void test_methodDoesntSupportJson() throws NoSuchFieldException, SecurityException, IllegalArgumentException, IllegalAccessException { diff --git a/here-oauth-client/src/test/java/com/here/account/http/java/JavaHttpProviderTest.java b/here-oauth-client/src/test/java/com/here/account/http/java/JavaHttpProviderTest.java index a2370643..3f139689 100644 --- a/here-oauth-client/src/test/java/com/here/account/http/java/JavaHttpProviderTest.java +++ b/here-oauth-client/src/test/java/com/here/account/http/java/JavaHttpProviderTest.java @@ -29,6 +29,7 @@ import java.net.MalformedURLException; import java.net.URL; import java.util.ArrayList; +import java.util.Arrays; import java.util.Collections; import java.util.HashMap; import java.util.List; @@ -254,6 +255,24 @@ public void test_getFormBody_second2() throws UnsupportedEncodingException { ); } + @Test + public void test_getFormBody_repeatedResourceParams() throws UnsupportedEncodingException { + Map> formParams = new HashMap>(); + formParams.put("grant_type", Collections.singletonList("client_credentials")); + formParams.put("resource", Arrays.asList("https://api.example.com", "https://data.example.com")); + byte[] formBody = JavaHttpProvider.getFormBody(formParams); + String body = new String(formBody, "UTF-8"); + long resourceCount = 0; + for (String part : body.split("&")) { + if (part.startsWith("resource=")) resourceCount++; + } + assertEquals("expected 2 resource= params in: " + body, 2, resourceCount); + assertTrue("body should contain resource=https%3A%2F%2Fapi.example.com: " + body, + body.contains("resource=https%3A%2F%2Fapi.example.com")); + assertTrue("body should contain resource=https%3A%2F%2Fdata.example.com: " + body, + body.contains("resource=https%3A%2F%2Fdata.example.com")); + } + @Test public void test_404() throws HttpException, IOException { 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 index 5a1d102e..8a494cb4 100644 --- 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 @@ -17,6 +17,8 @@ import org.junit.Test; +import java.util.Arrays; +import java.util.Collections; import java.util.List; import java.util.Map; @@ -89,4 +91,27 @@ public void testConstructor_rejectsNull() { public void testConstructor_rejectsEmpty() { new ClientAssertionCredentialsGrantRequest(""); } + + @Test + public void testFormParams_singleResource() { + ClientAssertionCredentialsGrantRequest request = new ClientAssertionCredentialsGrantRequest(FAKE_JWT) + .setResource(Collections.singletonList("https://example.com/api")); + Map> formParams = request.toFormParams(); + assertEquals(Collections.singletonList("https://example.com/api"), formParams.get("resource")); + } + + @Test + public void testFormParams_multipleResources() { + List resources = Arrays.asList("https://api.example.com", "https://data.example.com"); + ClientAssertionCredentialsGrantRequest request = new ClientAssertionCredentialsGrantRequest(FAKE_JWT) + .setResource(resources); + Map> formParams = request.toFormParams(); + assertEquals(resources, formParams.get("resource")); + } + + @Test + public void testFormParams_noResource() { + ClientAssertionCredentialsGrantRequest request = new ClientAssertionCredentialsGrantRequest(FAKE_JWT); + assertNull(request.toFormParams().get("resource")); + } } diff --git a/here-oauth-client/src/test/java/com/here/account/oauth2/ClientCredentialsGrantRequestTest.java b/here-oauth-client/src/test/java/com/here/account/oauth2/ClientCredentialsGrantRequestTest.java index 1df9c10c..3a5ed694 100644 --- a/here-oauth-client/src/test/java/com/here/account/oauth2/ClientCredentialsGrantRequestTest.java +++ b/here-oauth-client/src/test/java/com/here/account/oauth2/ClientCredentialsGrantRequestTest.java @@ -15,10 +15,13 @@ */ package com.here.account.oauth2; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; import static org.junit.Assert.assertTrue; import java.io.ByteArrayInputStream; import java.io.IOException; +import java.util.Arrays; import java.util.Collections; import java.util.HashMap; import java.util.List; @@ -81,6 +84,75 @@ public void test_ClientCredentialsGrantRequest_form_expiresIn() throws IOExcepti } + @Test + public void test_ClientCredentialsGrantRequest_form_singleResource() { + ClientCredentialsGrantRequest request = new ClientCredentialsGrantRequest() + .setResource(Collections.singletonList("https://example.com/api")); + Map> form = request.toFormParams(); + assertEquals(Collections.singletonList("https://example.com/api"), form.get("resource")); + } + + @Test + public void test_ClientCredentialsGrantRequest_form_multipleResources() { + List resources = Arrays.asList("https://api.example.com", "https://data.example.com"); + ClientCredentialsGrantRequest request = new ClientCredentialsGrantRequest() + .setResource(resources); + Map> form = request.toFormParams(); + assertEquals(resources, form.get("resource")); + } + + @Test + public void test_ClientCredentialsGrantRequest_form_noResource() { + ClientCredentialsGrantRequest request = new ClientCredentialsGrantRequest(); + Map> form = request.toFormParams(); + assertNull(form.get("resource")); + } + + @Test + public void test_ClientCredentialsGrantRequest_getResource() { + List resources = Collections.singletonList("https://example.com"); + ClientCredentialsGrantRequest request = new ClientCredentialsGrantRequest() + .setResource(resources); + assertEquals(resources, request.getResource()); + } + + @Test + public void test_addFormParams_nullFormParams_noOp() { + // must not throw + AccessTokenRequest.addFormParams(null, "resource", Collections.singletonList("https://example.com")); + } + + @Test + public void test_addFormParams_nullName_noOp() { + Map> formParams = new HashMap>(); + AccessTokenRequest.addFormParams(formParams, null, Collections.singletonList("https://example.com")); + assertTrue(formParams.isEmpty()); + } + + @Test + public void test_addFormParams_nullValues_noOp() { + Map> formParams = new HashMap>(); + AccessTokenRequest.addFormParams(formParams, "resource", null); + assertTrue(formParams.isEmpty()); + } + + @Test + public void test_addFormParams_emptyValues_noOp() { + Map> formParams = new HashMap>(); + AccessTokenRequest.addFormParams(formParams, "resource", Collections.emptyList()); + assertTrue(formParams.isEmpty()); + } + + @Test + public void test_addFormParams_defensiveCopy() { + Map> formParams = new HashMap>(); + List values = new java.util.ArrayList(Collections.singletonList("https://example.com")); + AccessTokenRequest.addFormParams(formParams, "resource", values); + values.add("https://other.com"); + assertEquals(1, formParams.get("resource").size()); + } + + private Map toMap(String json) throws IOException { byte[] bytes = json.getBytes(OAuthConstants.UTF_8_CHARSET); ByteArrayInputStream jsonInputStream = null; diff --git a/here-oauth-client/src/test/java/com/here/account/oauth2/HereAccountTest.java b/here-oauth-client/src/test/java/com/here/account/oauth2/HereAccountTest.java index f30935bf..6d580777 100644 --- a/here-oauth-client/src/test/java/com/here/account/oauth2/HereAccountTest.java +++ b/here-oauth-client/src/test/java/com/here/account/oauth2/HereAccountTest.java @@ -1076,4 +1076,47 @@ public void test_requestResponse_with_correlationId() { assertEquals(expectedCorrelationId, token.getCorrelationId()); } + + @Test + public void testRequestAutoRefreshingToken_propagatesResource() throws Exception { + HttpProvider mockHttpProvider = Mockito.mock(HttpProvider.class); + final String body = "{\"access_token\":\"my-token\",\"expires_in\":300}"; + final HttpProvider.HttpResponse mockHttpResponse = new HttpProvider.HttpResponse() { + @Override public int getStatusCode() { return 200; } + @Override public long getContentLength() { return body.getBytes(StandardCharsets.UTF_8).length; } + @Override public Map> getHeaders() { return new HashMap>(); } + @Override public InputStream getResponseBody() throws IOException { + return new ByteArrayInputStream(body.getBytes(StandardCharsets.UTF_8)); + } + }; + Mockito.when(mockHttpProvider.execute(Mockito.any())).thenReturn(mockHttpResponse); + + final List>> capturedParams = new ArrayList>>(); + Mockito.when(mockHttpProvider.getRequest( + Mockito.any(HttpProvider.HttpRequestAuthorizer.class), + Mockito.anyString(), Mockito.anyString(), + Mockito.any(Map.class))) + .thenAnswer(new org.mockito.stubbing.Answer() { + @Override + public HttpProvider.HttpRequest answer(org.mockito.invocation.InvocationOnMock inv) { + capturedParams.add((Map>) inv.getArguments()[3]); + return Mockito.mock(HttpProvider.HttpRequest.class); + } + }); + + TokenEndpoint tokenEndpoint = HereAccount.getTokenEndpoint( + mockHttpProvider, + new OAuth1ClientCredentialsProvider(new SettableSystemClock(), + url, accessKeyId, accessKeySecret, scope)); + + List resources = Arrays.asList("https://api.example.com", "https://data.example.com"); + ClientCredentialsGrantRequest request = new ClientCredentialsGrantRequest() + .setResource(resources); + + tokenEndpoint.requestAutoRefreshingToken(request); + + assertFalse("no requests were captured", capturedParams.isEmpty()); + List actualResource = capturedParams.get(0).get("resource"); + assertEquals("resource not propagated to auto-refresh request", resources, actualResource); + } } 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 16a545bd..6726051c 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 @@ -79,6 +79,8 @@ public void setUp() throws Exception { String sysPropTokenUrl = System.getProperty("here.token.endpoint.url", "https://stg.account.api.here.com/oauth2/token"); String sysPropScope = System.getProperty("here.jwt.token.scope"); + // RFC 8707 resource indicator — optional, used by test_clientAssertion_withResource + String sysPropResource = System.getProperty("here.jwt.token.resource"); if (sysPropClientId != null && sysPropPrivateKey != null) { props = new Properties(); @@ -92,6 +94,9 @@ public void setUp() throws Exception { if (sysPropScope != null) { props.setProperty(JwtClientAssertionProvider.TOKEN_SCOPE_PROPERTY, sysPropScope); } + if (sysPropResource != null) { + props.setProperty("here.token.resource", sysPropResource); + } } else { // Fall back to credentials file File credFile = getCredentialsFile(); @@ -234,6 +239,78 @@ public void test_clientAssertion_withoutKidInHeader() { assertFalse("accessToken must not be blank", response.getAccessToken().trim().isEmpty()); } + /** + * Test: RFC 8707 resource indicator parameter with private_key_jwt authentication. + * + *

RFC 8707 §2 defines {@code resource} as a per-request parameter identifying the + * target resource server for which the token is intended. It is orthogonal to the + * client authentication method — {@code client_assertion} authenticates the client + * while {@code resource} scopes the token's audience. Both can appear in the same + * token request body per RFC 8707 §2 and RFC 7523 §2.2. + * + *

The {@code resource} value is set on the request returned by + * {@link JwtClientAssertionProvider#getNewAccessTokenRequest()}, not baked into the + * provider, because RFC 8707 anticipates callers varying the resource per call. + * + *

Note: this test verifies the token is issued successfully. It does not assert + * audience binding in the returned JWT (e.g. that {@code aud} matches the resource URI) + * because the SDK treats access tokens as opaque strings and has no JWT parsing + * dependency. Audience binding and the "no resource ⇒ no aud restriction" behaviour + * are server-side concerns verified by server-side tests. + * + *

If {@code here.token.resource} is not configured in credentials or system + * properties ({@code -Dhere.jwt.token.resource=...}), this test is skipped. + */ + @Test + public void test_clientAssertion_withResource() { + String resource = props.getProperty("here.token.resource"); + Assume.assumeTrue("Skipping resource test: here.token.resource not set in credentials", + resource != null && !resource.isEmpty()); + + TokenEndpoint tokenEndpoint = HereAccount.getTokenEndpoint(httpProvider, provider); + // resource is a per-request parameter per RFC 8707 — get a fresh request and set it + AccessTokenRequest request = provider.getNewAccessTokenRequest(); + // single URI from config covers the common case; callers needing multiple resource + // indicators construct the list themselves and call setResource() directly + request.setResource(Collections.singletonList(resource)); + + AccessTokenResponse response = tokenEndpoint.requestToken(request); + + 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 test: an invalid {@code resource} value (relative URI, which violates + * RFC 8707 §2's absolute URI requirement) should be rejected by the server. + * + *

RFC 8707 §2 requires each resource value to be an absolute URI. The SDK does + * not validate this client-side (validation is the server's responsibility), so the + * error surfaces as an {@link AccessTokenException} with a 4xx status. The exact + * error code depends on the server's implementation of RFC 8707 §2 validation. + * + *

This test confirms that the SDK correctly propagates the server error rather + * than silently swallowing it or throwing an unrelated exception. + */ + @Test + public void test_clientAssertion_invalidResource_returns4xx() { + TokenEndpoint tokenEndpoint = HereAccount.getTokenEndpoint(httpProvider, provider); + AccessTokenRequest request = provider.getNewAccessTokenRequest(); + // A relative URI violates RFC 8707 §2 (absolute URI required); the server should reject it + request.setResource(Collections.singletonList("not-an-absolute-uri")); + + try { + tokenEndpoint.requestToken(request); + fail("Expected AccessTokenException for invalid resource URI"); + } catch (AccessTokenException e) { + // Server must return 4xx; exact code is server-defined per RFC 8707 + assertTrue("expected 4xx status for invalid resource, got " + e.getStatusCode(), + e.getStatusCode() >= 400 && e.getStatusCode() < 500); + assertNotNull("error response must not be null", e.getErrorResponse()); + } + } + /** * Test: request a project-scoped token using the scope parameter. * Requires the app to be a member of the project specified in