mirror of
https://github.com/keycloak/keycloak.git
synced 2025-12-16 20:15:46 -06:00
[OID4VCI] Relax CORS policy on credential offer endpoint (#43182)
Closes #43183 Signed-off-by: forkimenjeckayang <forkimenjeckayang@gmail.com> Signed-off-by: Awambeng Rodrick <awambengrodrick@gmail.com> Co-authored-by: Awambeng Rodrick <awambengrodrick@gmail.com>
This commit is contained in:
committed by
GitHub
parent
c8c110a049
commit
a05ed3154c
@@ -27,6 +27,7 @@ import jakarta.ws.rs.BadRequestException;
|
||||
import jakarta.ws.rs.Consumes;
|
||||
import jakarta.ws.rs.DefaultValue;
|
||||
import jakarta.ws.rs.GET;
|
||||
import jakarta.ws.rs.OPTIONS;
|
||||
import jakarta.ws.rs.POST;
|
||||
import jakarta.ws.rs.Path;
|
||||
import jakarta.ws.rs.PathParam;
|
||||
@@ -36,6 +37,9 @@ import jakarta.ws.rs.WebApplicationException;
|
||||
import jakarta.ws.rs.core.HttpHeaders;
|
||||
import jakarta.ws.rs.core.Response;
|
||||
import org.apache.commons.io.IOUtils;
|
||||
import org.apache.http.client.methods.HttpGet;
|
||||
import org.apache.http.client.methods.HttpOptions;
|
||||
import org.apache.http.client.methods.HttpPost;
|
||||
import org.jboss.logging.Logger;
|
||||
import org.keycloak.common.util.SecretGenerator;
|
||||
import org.keycloak.crypto.KeyUse;
|
||||
@@ -172,7 +176,7 @@ public class OID4VCIssuerEndpoint {
|
||||
private final int preAuthorizedCodeLifeSpan;
|
||||
|
||||
// constant for the OID4VCI enabled attribute key
|
||||
private static final String OID4VCI_ENABLED_ATTRIBUTE_KEY = "oid4vci.enabled";
|
||||
public static final String OID4VCI_ENABLED_ATTRIBUTE_KEY = "oid4vci.enabled";
|
||||
|
||||
/**
|
||||
* Credential builders are responsible for initiating the production of
|
||||
@@ -297,6 +301,19 @@ public class OID4VCIssuerEndpoint {
|
||||
return responseBuilder.build();
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles CORS preflight requests for credential offer URI endpoint.
|
||||
* Preflight requests return CORS headers for all origins (standard CORS behavior).
|
||||
* The actual request will validate origins against client configuration.
|
||||
*/
|
||||
@OPTIONS
|
||||
@Path("credential-offer-uri")
|
||||
public Response getCredentialOfferURIPreflight() {
|
||||
configureCors(true);
|
||||
cors.preflight();
|
||||
return cors.add(Response.ok());
|
||||
}
|
||||
|
||||
/**
|
||||
* Provides the URI to the OID4VCI compliant credentials offer
|
||||
*/
|
||||
@@ -304,16 +321,10 @@ public class OID4VCIssuerEndpoint {
|
||||
@Produces({MediaType.APPLICATION_JSON, RESPONSE_TYPE_IMG_PNG})
|
||||
@Path("credential-offer-uri")
|
||||
public Response getCredentialOfferURI(@QueryParam("credential_configuration_id") String vcId, @QueryParam("type") @DefaultValue("uri") OfferUriType type, @QueryParam("width") @DefaultValue("200") int width, @QueryParam("height") @DefaultValue("200") int height) {
|
||||
configureCors(true);
|
||||
|
||||
AuthenticatedClientSessionModel clientSession = getAuthenticatedClientSession();
|
||||
|
||||
// Initialize CORS configuration and validate if the client is enabled for OID4VCI
|
||||
cors = Cors.builder()
|
||||
.auth()
|
||||
.allowedMethods("GET")
|
||||
.auth()
|
||||
.exposedHeaders(Cors.ACCESS_CONTROL_ALLOW_METHODS);
|
||||
|
||||
cors.allowedOrigins(session, clientSession.getClient());
|
||||
checkClientEnabled();
|
||||
|
||||
Map<String, SupportedCredentialConfiguration> credentialsMap = OID4VCIssuerWellKnownProvider.getSupportedCredentials(session);
|
||||
@@ -321,7 +332,11 @@ public class OID4VCIssuerEndpoint {
|
||||
if (!credentialsMap.containsKey(vcId)) {
|
||||
LOGGER.debugf("No credential with id %s exists.", vcId);
|
||||
LOGGER.debugf("Supported credentials are %s.", credentialsMap);
|
||||
throw new BadRequestException(getErrorResponse(ErrorType.INVALID_CREDENTIAL_REQUEST));
|
||||
throw new CorsErrorResponseException(
|
||||
cors,
|
||||
ErrorType.INVALID_CREDENTIAL_REQUEST.toString(),
|
||||
"Invalid credential configuration ID",
|
||||
Response.Status.BAD_REQUEST);
|
||||
}
|
||||
SupportedCredentialConfiguration supportedCredentialConfiguration = credentialsMap.get(vcId);
|
||||
|
||||
@@ -350,14 +365,17 @@ public class OID4VCIssuerEndpoint {
|
||||
LOGGER.debugf("Stored credential configuration IDs for token processing: %s", credentialConfigIdsJson);
|
||||
} catch (JsonProcessingException e) {
|
||||
LOGGER.errorf("Could not convert the offer POJO to JSON: %s", e.getMessage());
|
||||
throw new BadRequestException(getErrorResponse(ErrorType.INVALID_CREDENTIAL_REQUEST));
|
||||
throw new CorsErrorResponseException(
|
||||
cors,
|
||||
ErrorType.INVALID_CREDENTIAL_REQUEST.toString(),
|
||||
"Failed to process credential offer",
|
||||
Response.Status.BAD_REQUEST);
|
||||
}
|
||||
|
||||
return switch (type) {
|
||||
case URI -> getOfferUriAsUri(sessionCode);
|
||||
case QR_CODE -> getOfferUriAsQr(sessionCode, width, height);
|
||||
};
|
||||
|
||||
}
|
||||
|
||||
private Response getOfferUriAsUri(String sessionCode) {
|
||||
@@ -365,10 +383,9 @@ public class OID4VCIssuerEndpoint {
|
||||
.setIssuer(OID4VCIssuerWellKnownProvider.getIssuer(session.getContext()) + "/protocol/" + OID4VCLoginProtocolFactory.PROTOCOL_ID + "/" + CREDENTIAL_OFFER_PATH)
|
||||
.setNonce(sessionCode);
|
||||
|
||||
return Response.ok()
|
||||
return cors.add(Response.ok()
|
||||
.type(MediaType.APPLICATION_JSON)
|
||||
.entity(credentialOfferURI)
|
||||
.build();
|
||||
.entity(credentialOfferURI));
|
||||
}
|
||||
|
||||
private Response getOfferUriAsQr(String sessionCode, int width, int height) {
|
||||
@@ -378,13 +395,37 @@ public class OID4VCIssuerEndpoint {
|
||||
BitMatrix bitMatrix = qrCodeWriter.encode("openid-credential-offer://?credential_offer_uri=" + encodedOfferUri, BarcodeFormat.QR_CODE, width, height);
|
||||
ByteArrayOutputStream bos = new ByteArrayOutputStream();
|
||||
MatrixToImageWriter.writeToStream(bitMatrix, "png", bos);
|
||||
return Response.ok().type(RESPONSE_TYPE_IMG_PNG).entity(bos.toByteArray()).build();
|
||||
return cors.add(Response.ok().type(RESPONSE_TYPE_IMG_PNG).entity(bos.toByteArray()));
|
||||
} catch (WriterException | IOException e) {
|
||||
LOGGER.warnf("Was not able to create a qr code of dimension %s:%s.", width, height, e);
|
||||
return Response.status(Response.Status.INTERNAL_SERVER_ERROR).entity("Was not able to generate qr.").build();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Configures basic CORS for error responses before authentication
|
||||
*/
|
||||
private void configureCors(boolean authenticated) {
|
||||
cors = Cors.builder()
|
||||
.allowedMethods(HttpGet.METHOD_NAME, HttpOptions.METHOD_NAME)
|
||||
.allowAllOrigins()
|
||||
.exposedHeaders(Cors.ACCESS_CONTROL_ALLOW_METHODS, HttpHeaders.CONTENT_TYPE);
|
||||
if (authenticated) {
|
||||
cors = cors.auth();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles CORS preflight requests for credential offer endpoint
|
||||
*/
|
||||
@OPTIONS
|
||||
@Path(CREDENTIAL_OFFER_PATH + "{sessionCode}")
|
||||
public Response getCredentialOfferPreflight(@PathParam("sessionCode") String sessionCode) {
|
||||
configureCors(false);
|
||||
cors.preflight();
|
||||
return cors.add(Response.ok());
|
||||
}
|
||||
|
||||
/**
|
||||
* Provides an OID4VCI compliant credential offer
|
||||
*/
|
||||
@@ -392,6 +433,8 @@ public class OID4VCIssuerEndpoint {
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
@Path(CREDENTIAL_OFFER_PATH + "{sessionCode}")
|
||||
public Response getCredentialOffer(@PathParam("sessionCode") String sessionCode) {
|
||||
configureCors(false);
|
||||
|
||||
if (sessionCode == null) {
|
||||
throw new BadRequestException(getErrorResponse(ErrorType.INVALID_CREDENTIAL_REQUEST));
|
||||
}
|
||||
@@ -399,9 +442,8 @@ public class OID4VCIssuerEndpoint {
|
||||
CredentialsOffer credentialsOffer = getOfferFromSessionCode(sessionCode);
|
||||
LOGGER.debugf("Responding with offer: %s", credentialsOffer);
|
||||
|
||||
return Response.ok()
|
||||
.entity(credentialsOffer)
|
||||
.build();
|
||||
return cors.add(Response.ok()
|
||||
.entity(credentialsOffer));
|
||||
}
|
||||
|
||||
private void checkScope(CredentialScopeModel requestedCredential) {
|
||||
@@ -445,7 +487,7 @@ public class OID4VCIssuerEndpoint {
|
||||
throw new BadRequestException(getErrorResponse(ErrorType.INVALID_CREDENTIAL_REQUEST, errorMessage));
|
||||
}
|
||||
|
||||
cors = Cors.builder().auth().allowedMethods("POST").auth().exposedHeaders(Cors.ACCESS_CONTROL_ALLOW_METHODS);
|
||||
cors = Cors.builder().auth().allowedMethods(HttpPost.METHOD_NAME).auth().exposedHeaders(Cors.ACCESS_CONTROL_ALLOW_METHODS);
|
||||
|
||||
CredentialIssuer issuerMetadata = (CredentialIssuer) new OID4VCIssuerWellKnownProvider(session).getConfig();
|
||||
|
||||
@@ -953,7 +995,11 @@ public class OID4VCIssuerEndpoint {
|
||||
getAuthenticatedClientSessionByClient(
|
||||
authResult.client().getId());
|
||||
if (clientSession == null) {
|
||||
throw new BadRequestException(getErrorResponse(ErrorType.INVALID_TOKEN));
|
||||
throw new CorsErrorResponseException(
|
||||
cors,
|
||||
ErrorType.INVALID_TOKEN.toString(),
|
||||
"Invalid or missing token",
|
||||
Response.Status.BAD_REQUEST);
|
||||
}
|
||||
return clientSession;
|
||||
}
|
||||
@@ -961,7 +1007,11 @@ public class OID4VCIssuerEndpoint {
|
||||
private AuthenticationManager.AuthResult getAuthResult() {
|
||||
AuthenticationManager.AuthResult authResult = bearerTokenAuthenticator.authenticate();
|
||||
if (authResult == null) {
|
||||
throw new BadRequestException(getErrorResponse(ErrorType.INVALID_TOKEN));
|
||||
throw new CorsErrorResponseException(
|
||||
cors,
|
||||
ErrorType.INVALID_TOKEN.toString(),
|
||||
"Invalid or missing token",
|
||||
Response.Status.BAD_REQUEST);
|
||||
}
|
||||
|
||||
// Validate DPoP nonce if present in the DPoP proof
|
||||
@@ -982,7 +1032,11 @@ public class OID4VCIssuerEndpoint {
|
||||
);
|
||||
} catch (VerificationException e) {
|
||||
LOGGER.debugf("DPoP nonce validation failed: %s", e.getMessage());
|
||||
throw new BadRequestException(getErrorResponse(ErrorType.INVALID_TOKEN));
|
||||
throw new CorsErrorResponseException(
|
||||
cors,
|
||||
ErrorType.INVALID_TOKEN.toString(),
|
||||
"Invalid or missing token",
|
||||
Response.Status.BAD_REQUEST);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,360 @@
|
||||
/*
|
||||
* Copyright 2025 Red Hat, Inc. and/or its affiliates
|
||||
* and other contributors as indicated by the @author tags.
|
||||
*
|
||||
* 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 org.keycloak.testsuite.oid4vc.issuance.signing;
|
||||
|
||||
import jakarta.ws.rs.core.HttpHeaders;
|
||||
import org.apache.http.Header;
|
||||
import org.apache.http.HttpStatus;
|
||||
import org.apache.http.client.methods.CloseableHttpResponse;
|
||||
import org.apache.http.client.methods.HttpGet;
|
||||
import org.apache.http.client.methods.HttpOptions;
|
||||
import org.junit.Rule;
|
||||
import org.junit.Test;
|
||||
import org.keycloak.protocol.oid4vc.model.CredentialOfferURI;
|
||||
import org.keycloak.protocol.oid4vc.model.CredentialsOffer;
|
||||
import org.keycloak.testsuite.util.oauth.AccessTokenResponse;
|
||||
import org.keycloak.representations.idm.RealmRepresentation;
|
||||
import org.keycloak.services.cors.Cors;
|
||||
import org.keycloak.testsuite.AssertEvents;
|
||||
import org.keycloak.testsuite.arquillian.annotation.EnableFeature;
|
||||
import org.keycloak.testsuite.util.TokenUtil;
|
||||
import org.keycloak.util.JsonSerialization;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.Arrays;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertNotNull;
|
||||
import static org.junit.Assert.assertNull;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
import static org.keycloak.testsuite.forms.PassThroughClientAuthenticator.clientId;
|
||||
|
||||
import org.keycloak.common.Profile;
|
||||
|
||||
/**
|
||||
* Test class for CORS functionality on OID4VCI credential offer endpoints.
|
||||
* Tests both the authenticated credential-offer-uri endpoint and the
|
||||
* session-based credential-offer/{sessionCode} endpoint.
|
||||
*
|
||||
* @author <a href="https://github.com/forkimenjeckayang">Forkim Akwichek</a>
|
||||
*/
|
||||
@EnableFeature(value = Profile.Feature.OID4VC_VCI, skipRestart = true)
|
||||
public class OID4VCCredentialOfferCorsTest extends OID4VCIssuerEndpointTest {
|
||||
|
||||
private static final String VALID_CORS_URL = "http://localtest.me:8180";
|
||||
private static final String INVALID_CORS_URL = "http://invalid.localtest.me:8180";
|
||||
private static final String ANOTHER_VALID_CORS_URL = "http://another.localtest.me:8180";
|
||||
|
||||
@Rule
|
||||
public AssertEvents events = new AssertEvents(this);
|
||||
|
||||
@Rule
|
||||
public TokenUtil tokenUtil = new TokenUtil();
|
||||
|
||||
|
||||
@Override
|
||||
public void configureTestRealm(RealmRepresentation testRealm) {
|
||||
super.configureTestRealm(testRealm);
|
||||
|
||||
// Find the existing client and add web origins for CORS testing
|
||||
testRealm.getClients().stream()
|
||||
.filter(client -> client.getClientId().equals(clientId))
|
||||
.findFirst()
|
||||
.ifPresent(client -> {
|
||||
client.setDirectAccessGrantsEnabled(true);
|
||||
client.setWebOrigins(Arrays.asList(VALID_CORS_URL, ANOTHER_VALID_CORS_URL));
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testCredentialOfferUriCorsValidOrigin() throws Exception {
|
||||
|
||||
AccessTokenResponse tokenResponse = getAccessToken();
|
||||
|
||||
// Test credential offer URI endpoint with valid origin
|
||||
String offerUriUrl = getCredentialOfferUriUrl();
|
||||
|
||||
try (CloseableHttpResponse response = makeCorsRequest(offerUriUrl, VALID_CORS_URL, tokenResponse.getAccessToken())) {
|
||||
assertEquals(HttpStatus.SC_OK, response.getStatusLine().getStatusCode());
|
||||
assertCorsHeaders(response, VALID_CORS_URL);
|
||||
|
||||
// Verify response content
|
||||
String responseBody = getResponseBody(response);
|
||||
CredentialOfferURI offerUri = JsonSerialization.readValue(responseBody, CredentialOfferURI.class);
|
||||
assertNotNull("Credential offer URI should not be null", offerUri.getIssuer());
|
||||
assertNotNull("Nonce should not be null", offerUri.getNonce());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testCredentialOfferUriCorsInvalidOrigin() throws Exception {
|
||||
|
||||
AccessTokenResponse tokenResponse = getAccessToken();
|
||||
|
||||
// Test credential offer URI endpoint with invalid origin
|
||||
String offerUriUrl = getCredentialOfferUriUrl();
|
||||
|
||||
try (CloseableHttpResponse response = makeCorsRequest(offerUriUrl, INVALID_CORS_URL, tokenResponse.getAccessToken())) {
|
||||
// Should still return 200 OK but without CORS headers
|
||||
assertEquals(HttpStatus.SC_OK, response.getStatusLine().getStatusCode());
|
||||
assertNoCorsHeaders(response);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testCredentialOfferUriCorsPreflightRequest() throws Exception {
|
||||
// Test preflight request for credential offer URI endpoint
|
||||
String offerUriUrl = getCredentialOfferUriUrl();
|
||||
|
||||
try (CloseableHttpResponse response = makePreflightRequest(offerUriUrl, VALID_CORS_URL)) {
|
||||
assertEquals(HttpStatus.SC_OK, response.getStatusLine().getStatusCode());
|
||||
assertCorsPreflightHeaders(response, VALID_CORS_URL);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testCredentialOfferUriCorsPreflightInvalidOrigin() throws Exception {
|
||||
// Test preflight request with invalid origin
|
||||
String offerUriUrl = getCredentialOfferUriUrl();
|
||||
|
||||
try (CloseableHttpResponse response = makePreflightRequest(offerUriUrl, INVALID_CORS_URL)) {
|
||||
// Preflight should succeed and return CORS headers for all origins (standard CORS behavior)
|
||||
assertEquals(HttpStatus.SC_OK, response.getStatusLine().getStatusCode());
|
||||
assertCorsPreflightHeaders(response, INVALID_CORS_URL);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testCredentialOfferSessionCorsValidOrigin() throws Exception {
|
||||
// First get a credential offer URI to obtain a session code
|
||||
AccessTokenResponse tokenResponse = getAccessToken();
|
||||
String sessionCode = getSessionCodeFromOfferUri(tokenResponse.getAccessToken());
|
||||
|
||||
// Test credential offer endpoint with valid origin
|
||||
String offerUrl = getCredentialOfferUrl(sessionCode);
|
||||
|
||||
try (CloseableHttpResponse response = makeCorsRequest(offerUrl, VALID_CORS_URL, null)) {
|
||||
assertEquals(HttpStatus.SC_OK, response.getStatusLine().getStatusCode());
|
||||
assertCorsHeadersForSessionEndpoint(response, VALID_CORS_URL);
|
||||
|
||||
// Verify response content
|
||||
String responseBody = getResponseBody(response);
|
||||
CredentialsOffer offer = JsonSerialization.readValue(responseBody, CredentialsOffer.class);
|
||||
assertNotNull("Credential offer should not be null", offer.getCredentialIssuer());
|
||||
assertNotNull("Credential configuration IDs should not be null", offer.getCredentialConfigurationIds());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testCredentialOfferSessionCorsInvalidOrigin() throws Exception {
|
||||
// First get a credential offer URI to obtain a session code
|
||||
AccessTokenResponse tokenResponse = getAccessToken();
|
||||
String sessionCode = getSessionCodeFromOfferUri(tokenResponse.getAccessToken());
|
||||
|
||||
// Test credential offer endpoint with invalid origin
|
||||
String offerUrl = getCredentialOfferUrl(sessionCode);
|
||||
|
||||
try (CloseableHttpResponse response = makeCorsRequest(offerUrl, INVALID_CORS_URL, null)) {
|
||||
// Should still return 200 OK and include CORS headers (allows all origins)
|
||||
assertEquals(HttpStatus.SC_OK, response.getStatusLine().getStatusCode());
|
||||
assertCorsHeadersForSessionEndpoint(response, INVALID_CORS_URL);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testCredentialOfferSessionCorsPreflightRequest() throws Exception {
|
||||
// First get a credential offer URI to obtain a session code
|
||||
AccessTokenResponse tokenResponse = getAccessToken();
|
||||
String sessionCode = getSessionCodeFromOfferUri(tokenResponse.getAccessToken());
|
||||
|
||||
// Test preflight request for credential offer endpoint
|
||||
String offerUrl = getCredentialOfferUrl(sessionCode);
|
||||
|
||||
try (CloseableHttpResponse response = makePreflightRequest(offerUrl, VALID_CORS_URL)) {
|
||||
assertEquals(HttpStatus.SC_OK, response.getStatusLine().getStatusCode());
|
||||
assertCorsPreflightHeadersForSessionEndpoint(response, VALID_CORS_URL);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testCredentialOfferUriQrCodeCorsValidOrigin() throws Exception {
|
||||
|
||||
AccessTokenResponse tokenResponse = getAccessToken();
|
||||
|
||||
// Test credential offer URI QR code endpoint with valid origin
|
||||
String offerUriUrl = getCredentialOfferUriUrl() + "&type=qr-code";
|
||||
|
||||
try (CloseableHttpResponse response = makeCorsRequest(offerUriUrl, VALID_CORS_URL, tokenResponse.getAccessToken())) {
|
||||
assertEquals(HttpStatus.SC_OK, response.getStatusLine().getStatusCode());
|
||||
assertCorsHeaders(response, VALID_CORS_URL);
|
||||
|
||||
// Verify response is PNG image
|
||||
String contentType = response.getFirstHeader("Content-Type").getValue();
|
||||
assertTrue("Response should be PNG image", contentType.contains("image/png"));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testMultipleValidOrigins() throws Exception {
|
||||
// Test that multiple valid origins work
|
||||
AccessTokenResponse tokenResponse = getAccessToken();
|
||||
String offerUriUrl = getCredentialOfferUriUrl();
|
||||
|
||||
// Test with first valid origin
|
||||
try (CloseableHttpResponse response1 = makeCorsRequest(offerUriUrl, VALID_CORS_URL, tokenResponse.getAccessToken())) {
|
||||
assertEquals(HttpStatus.SC_OK, response1.getStatusLine().getStatusCode());
|
||||
assertCorsHeaders(response1, VALID_CORS_URL);
|
||||
}
|
||||
|
||||
// Test with second valid origin
|
||||
try (CloseableHttpResponse response2 = makeCorsRequest(offerUriUrl, ANOTHER_VALID_CORS_URL, tokenResponse.getAccessToken())) {
|
||||
assertEquals(HttpStatus.SC_OK, response2.getStatusLine().getStatusCode());
|
||||
assertCorsHeaders(response2, ANOTHER_VALID_CORS_URL);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testUnauthenticatedCredentialOfferUri() throws Exception {
|
||||
// Test credential offer URI endpoint without authentication
|
||||
String offerUriUrl = getCredentialOfferUriUrl();
|
||||
|
||||
try (CloseableHttpResponse response = makeCorsRequest(offerUriUrl, VALID_CORS_URL, null)) {
|
||||
// Should return 400 Bad Request
|
||||
assertEquals(HttpStatus.SC_BAD_REQUEST, response.getStatusLine().getStatusCode());
|
||||
// Should still include CORS headers for error responses
|
||||
assertCorsHeaders(response, VALID_CORS_URL);
|
||||
}
|
||||
}
|
||||
|
||||
// Helper methods
|
||||
|
||||
private AccessTokenResponse getAccessToken() throws Exception {
|
||||
oauth.realm("test");
|
||||
oauth.client(client.getClientId(), client.getSecret());
|
||||
|
||||
return oauth.doPasswordGrantRequest("john", "password");
|
||||
}
|
||||
|
||||
private String getCredentialOfferUriUrl() {
|
||||
return getBasePath("test") + "credential-offer-uri?credential_configuration_id=" + jwtTypeCredentialConfigurationIdName;
|
||||
}
|
||||
|
||||
private String getCredentialOfferUrl(String sessionCode) {
|
||||
return getBasePath("test") + "credential-offer/" + sessionCode;
|
||||
}
|
||||
|
||||
private String getSessionCodeFromOfferUri(String accessToken) throws Exception {
|
||||
String offerUriUrl = getCredentialOfferUriUrl();
|
||||
|
||||
try (CloseableHttpResponse response = makeCorsRequest(offerUriUrl, VALID_CORS_URL, accessToken)) {
|
||||
assertEquals(HttpStatus.SC_OK, response.getStatusLine().getStatusCode());
|
||||
|
||||
String responseBody = getResponseBody(response);
|
||||
CredentialOfferURI offerUri = JsonSerialization.readValue(responseBody, CredentialOfferURI.class);
|
||||
|
||||
return offerUri.getNonce();
|
||||
}
|
||||
}
|
||||
|
||||
private CloseableHttpResponse makeCorsRequest(String url, String origin, String accessToken) throws IOException {
|
||||
HttpGet request = new HttpGet(url);
|
||||
request.setHeader("Origin", origin);
|
||||
request.setHeader("Accept", "application/json");
|
||||
|
||||
if (accessToken != null) {
|
||||
request.setHeader(HttpHeaders.AUTHORIZATION, "Bearer " + accessToken);
|
||||
}
|
||||
|
||||
return httpClient.execute(request);
|
||||
}
|
||||
|
||||
private CloseableHttpResponse makePreflightRequest(String url, String origin) throws IOException {
|
||||
HttpOptions request = new HttpOptions(url);
|
||||
request.setHeader("Origin", origin);
|
||||
request.setHeader("Access-Control-Request-Method", "GET");
|
||||
request.setHeader("Access-Control-Request-Headers", "Authorization, Accept");
|
||||
|
||||
return httpClient.execute(request);
|
||||
}
|
||||
|
||||
private String getResponseBody(CloseableHttpResponse response) throws IOException {
|
||||
return new String(response.getEntity().getContent().readAllBytes());
|
||||
}
|
||||
|
||||
private void assertCorsHeaders(CloseableHttpResponse response, String expectedOrigin) {
|
||||
assertNotNull("Access-Control-Allow-Origin header should be present",
|
||||
response.getFirstHeader(Cors.ACCESS_CONTROL_ALLOW_ORIGIN));
|
||||
assertEquals("Access-Control-Allow-Origin should match request origin",
|
||||
expectedOrigin, response.getFirstHeader(Cors.ACCESS_CONTROL_ALLOW_ORIGIN).getValue());
|
||||
|
||||
assertNotNull("Access-Control-Allow-Credentials header should be present",
|
||||
response.getFirstHeader(Cors.ACCESS_CONTROL_ALLOW_CREDENTIALS));
|
||||
assertEquals("Access-Control-Allow-Credentials should be true",
|
||||
"true", response.getFirstHeader(Cors.ACCESS_CONTROL_ALLOW_CREDENTIALS).getValue());
|
||||
}
|
||||
|
||||
private void assertCorsHeadersForSessionEndpoint(CloseableHttpResponse response, String expectedOrigin) {
|
||||
assertNotNull("Access-Control-Allow-Origin header should be present",
|
||||
response.getFirstHeader(Cors.ACCESS_CONTROL_ALLOW_ORIGIN));
|
||||
assertEquals("Access-Control-Allow-Origin should match request origin",
|
||||
expectedOrigin, response.getFirstHeader(Cors.ACCESS_CONTROL_ALLOW_ORIGIN).getValue());
|
||||
|
||||
// Session-based endpoints don't require credentials since they use session codes for security
|
||||
// and allow all origins, so credentials header should be false for security reasons
|
||||
Header credentialsHeader = response.getFirstHeader(Cors.ACCESS_CONTROL_ALLOW_CREDENTIALS);
|
||||
assertNotNull("Access-Control-Allow-Credentials header should be present for session endpoints",
|
||||
credentialsHeader);
|
||||
assertEquals("Access-Control-Allow-Credentials should be false when allowing all origins",
|
||||
"false", credentialsHeader.getValue());
|
||||
}
|
||||
|
||||
private void assertCorsPreflightHeaders(CloseableHttpResponse response, String expectedOrigin) {
|
||||
assertCorsHeaders(response, expectedOrigin);
|
||||
|
||||
assertNotNull("Access-Control-Allow-Methods header should be present",
|
||||
response.getFirstHeader(Cors.ACCESS_CONTROL_ALLOW_METHODS));
|
||||
|
||||
String allowedMethods = response.getFirstHeader(Cors.ACCESS_CONTROL_ALLOW_METHODS).getValue();
|
||||
Set<String> methods = Arrays.stream(allowedMethods.split(", "))
|
||||
.collect(Collectors.toSet());
|
||||
|
||||
assertTrue("GET should be allowed", methods.contains("GET"));
|
||||
assertTrue("OPTIONS should be allowed", methods.contains("OPTIONS"));
|
||||
}
|
||||
|
||||
private void assertCorsPreflightHeadersForSessionEndpoint(CloseableHttpResponse response, String expectedOrigin) {
|
||||
assertCorsHeadersForSessionEndpoint(response, expectedOrigin);
|
||||
|
||||
assertNotNull("Access-Control-Allow-Methods header should be present",
|
||||
response.getFirstHeader(Cors.ACCESS_CONTROL_ALLOW_METHODS));
|
||||
|
||||
String allowedMethods = response.getFirstHeader(Cors.ACCESS_CONTROL_ALLOW_METHODS).getValue();
|
||||
Set<String> methods = Arrays.stream(allowedMethods.split(", "))
|
||||
.collect(Collectors.toSet());
|
||||
|
||||
assertTrue("GET should be allowed", methods.contains("GET"));
|
||||
assertTrue("OPTIONS should be allowed", methods.contains("OPTIONS"));
|
||||
}
|
||||
|
||||
private void assertNoCorsHeaders(CloseableHttpResponse response) {
|
||||
assertNull("Access-Control-Allow-Origin header should not be present", response.getFirstHeader(Cors.ACCESS_CONTROL_ALLOW_ORIGIN));
|
||||
assertNull("Access-Control-Allow-Credentials header should not be present", response.getFirstHeader(Cors.ACCESS_CONTROL_ALLOW_CREDENTIALS));
|
||||
}
|
||||
}
|
||||
@@ -60,6 +60,7 @@ import org.keycloak.protocol.oidc.grants.PreAuthorizedCodeGrantTypeFactory;
|
||||
import org.keycloak.protocol.oidc.representations.OIDCConfigurationRepresentation;
|
||||
import org.keycloak.representations.JsonWebToken;
|
||||
import org.keycloak.sdjwt.vp.SdJwtVP;
|
||||
import org.keycloak.services.CorsErrorResponseException;
|
||||
import org.keycloak.services.managers.AppAuthManager;
|
||||
import org.keycloak.testsuite.util.oauth.AccessTokenResponse;
|
||||
import org.keycloak.util.JsonSerialization;
|
||||
@@ -87,41 +88,51 @@ import static org.junit.Assert.fail;
|
||||
public class OID4VCJWTIssuerEndpointTest extends OID4VCIssuerEndpointTest {
|
||||
// ----- getCredentialOfferUri
|
||||
|
||||
@Test(expected = BadRequestException.class)
|
||||
public void testGetCredentialOfferUriUnsupportedCredential() throws Throwable {
|
||||
@Test
|
||||
public void testGetCredentialOfferUriUnsupportedCredential() {
|
||||
String token = getBearerToken(oauth);
|
||||
withCausePropagation(() -> testingClient.server(TEST_REALM_NAME)
|
||||
.run((session -> {
|
||||
AppAuthManager.BearerTokenAuthenticator authenticator = new AppAuthManager.BearerTokenAuthenticator(session);
|
||||
authenticator.setTokenString(token);
|
||||
testingClient.server(TEST_REALM_NAME).run((session -> {
|
||||
AppAuthManager.BearerTokenAuthenticator authenticator = new AppAuthManager.BearerTokenAuthenticator(session);
|
||||
authenticator.setTokenString(token);
|
||||
|
||||
OID4VCIssuerEndpoint oid4VCIssuerEndpoint = prepareIssuerEndpoint(session, authenticator);
|
||||
oid4VCIssuerEndpoint.getCredentialOfferURI("inexistent-id", OfferUriType.URI, 0, 0);
|
||||
})));
|
||||
OID4VCIssuerEndpoint oid4VCIssuerEndpoint = prepareIssuerEndpoint(session, authenticator);
|
||||
|
||||
CorsErrorResponseException exception = Assert.assertThrows(CorsErrorResponseException.class, () ->
|
||||
oid4VCIssuerEndpoint.getCredentialOfferURI("inexistent-id", OfferUriType.URI, 0, 0)
|
||||
);
|
||||
assertEquals("Should return BAD_REQUEST", Response.Status.BAD_REQUEST.getStatusCode(),
|
||||
exception.getResponse().getStatus());
|
||||
}));
|
||||
}
|
||||
|
||||
@Test(expected = BadRequestException.class)
|
||||
public void testGetCredentialOfferUriUnauthorized() throws Throwable {
|
||||
withCausePropagation(() -> testingClient.server(TEST_REALM_NAME)
|
||||
.run((session -> {
|
||||
AppAuthManager.BearerTokenAuthenticator authenticator = new AppAuthManager.BearerTokenAuthenticator(session);
|
||||
authenticator.setTokenString(null);
|
||||
OID4VCIssuerEndpoint oid4VCIssuerEndpoint = prepareIssuerEndpoint(session, authenticator);
|
||||
oid4VCIssuerEndpoint.getCredentialOfferURI("test-credential", OfferUriType.URI, 0, 0);
|
||||
})));
|
||||
@Test
|
||||
public void testGetCredentialOfferUriUnauthorized() {
|
||||
testingClient.server(TEST_REALM_NAME).run((session -> {
|
||||
AppAuthManager.BearerTokenAuthenticator authenticator = new AppAuthManager.BearerTokenAuthenticator(session);
|
||||
authenticator.setTokenString(null);
|
||||
OID4VCIssuerEndpoint oid4VCIssuerEndpoint = prepareIssuerEndpoint(session, authenticator);
|
||||
|
||||
CorsErrorResponseException exception = Assert.assertThrows(CorsErrorResponseException.class, () ->
|
||||
oid4VCIssuerEndpoint.getCredentialOfferURI("test-credential", OfferUriType.URI, 0, 0)
|
||||
);
|
||||
assertEquals("Should return BAD_REQUEST", Response.Status.BAD_REQUEST.getStatusCode(),
|
||||
exception.getResponse().getStatus());
|
||||
}));
|
||||
}
|
||||
|
||||
@Test(expected = BadRequestException.class)
|
||||
public void testGetCredentialOfferUriInvalidToken() throws Throwable {
|
||||
withCausePropagation(() -> testingClient.server(TEST_REALM_NAME)
|
||||
.run((session -> {
|
||||
AppAuthManager.BearerTokenAuthenticator authenticator = new AppAuthManager.BearerTokenAuthenticator(session);
|
||||
authenticator.setTokenString("invalid-token");
|
||||
OID4VCIssuerEndpoint oid4VCIssuerEndpoint = prepareIssuerEndpoint(session, authenticator);
|
||||
Response response = oid4VCIssuerEndpoint
|
||||
.getCredentialOfferURI("test-credential", OfferUriType.URI, 0, 0);
|
||||
assertEquals(MediaType.APPLICATION_JSON_TYPE, response.getMediaType());
|
||||
})));
|
||||
@Test
|
||||
public void testGetCredentialOfferUriInvalidToken() {
|
||||
testingClient.server(TEST_REALM_NAME).run((session -> {
|
||||
AppAuthManager.BearerTokenAuthenticator authenticator = new AppAuthManager.BearerTokenAuthenticator(session);
|
||||
authenticator.setTokenString("invalid-token");
|
||||
OID4VCIssuerEndpoint oid4VCIssuerEndpoint = prepareIssuerEndpoint(session, authenticator);
|
||||
|
||||
CorsErrorResponseException exception = Assert.assertThrows(CorsErrorResponseException.class, () ->
|
||||
oid4VCIssuerEndpoint.getCredentialOfferURI("test-credential", OfferUriType.URI, 0, 0)
|
||||
);
|
||||
assertEquals("Should return BAD_REQUEST", Response.Status.BAD_REQUEST.getStatusCode(),
|
||||
exception.getResponse().getStatus());
|
||||
}));
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -247,39 +258,42 @@ public class OID4VCJWTIssuerEndpointTest extends OID4VCIssuerEndpointTest {
|
||||
|
||||
// ----- requestCredential
|
||||
|
||||
@Test(expected = BadRequestException.class)
|
||||
public void testRequestCredentialUnauthorized() throws Throwable {
|
||||
withCausePropagation(() -> {
|
||||
testingClient.server(TEST_REALM_NAME).run(session -> {
|
||||
AppAuthManager.BearerTokenAuthenticator authenticator = new AppAuthManager.BearerTokenAuthenticator(session);
|
||||
authenticator.setTokenString(null);
|
||||
OID4VCIssuerEndpoint issuerEndpoint = prepareIssuerEndpoint(session, authenticator);
|
||||
CredentialRequest credentialRequest = new CredentialRequest()
|
||||
.setCredentialIdentifier("test-credential");
|
||||
@Test
|
||||
public void testRequestCredentialUnauthorized() {
|
||||
testingClient.server(TEST_REALM_NAME).run(session -> {
|
||||
AppAuthManager.BearerTokenAuthenticator authenticator = new AppAuthManager.BearerTokenAuthenticator(session);
|
||||
authenticator.setTokenString(null);
|
||||
OID4VCIssuerEndpoint issuerEndpoint = prepareIssuerEndpoint(session, authenticator);
|
||||
CredentialRequest credentialRequest = new CredentialRequest()
|
||||
.setCredentialIdentifier("test-credential");
|
||||
|
||||
String requestPayload;
|
||||
requestPayload = JsonSerialization.writeValueAsString(credentialRequest);
|
||||
String requestPayload;
|
||||
requestPayload = JsonSerialization.writeValueAsString(credentialRequest);
|
||||
|
||||
Response response = issuerEndpoint.requestCredential(requestPayload);
|
||||
assertEquals(MediaType.APPLICATION_JSON_TYPE, response.getMediaType());
|
||||
});
|
||||
CorsErrorResponseException exception = Assert.assertThrows(CorsErrorResponseException.class, () ->
|
||||
issuerEndpoint.requestCredential(requestPayload)
|
||||
);
|
||||
assertEquals("Should return BAD_REQUEST", Response.Status.BAD_REQUEST.getStatusCode(),
|
||||
exception.getResponse().getStatus());
|
||||
});
|
||||
}
|
||||
|
||||
@Test(expected = BadRequestException.class)
|
||||
public void testRequestCredentialInvalidToken() throws Throwable {
|
||||
withCausePropagation(() -> {
|
||||
testingClient.server(TEST_REALM_NAME).run(session -> {
|
||||
AppAuthManager.BearerTokenAuthenticator authenticator = new AppAuthManager.BearerTokenAuthenticator(session);
|
||||
authenticator.setTokenString("token");
|
||||
OID4VCIssuerEndpoint issuerEndpoint = prepareIssuerEndpoint(session, authenticator);
|
||||
CredentialRequest credentialRequest = new CredentialRequest()
|
||||
.setCredentialIdentifier("test-credential");
|
||||
@Test
|
||||
public void testRequestCredentialInvalidToken() {
|
||||
testingClient.server(TEST_REALM_NAME).run(session -> {
|
||||
AppAuthManager.BearerTokenAuthenticator authenticator = new AppAuthManager.BearerTokenAuthenticator(session);
|
||||
authenticator.setTokenString("token");
|
||||
OID4VCIssuerEndpoint issuerEndpoint = prepareIssuerEndpoint(session, authenticator);
|
||||
CredentialRequest credentialRequest = new CredentialRequest()
|
||||
.setCredentialIdentifier("test-credential");
|
||||
|
||||
String requestPayload = JsonSerialization.writeValueAsString(credentialRequest);
|
||||
String requestPayload = JsonSerialization.writeValueAsString(credentialRequest);
|
||||
|
||||
issuerEndpoint.requestCredential(requestPayload);
|
||||
});
|
||||
CorsErrorResponseException exception = Assert.assertThrows(CorsErrorResponseException.class, () ->
|
||||
issuerEndpoint.requestCredential(requestPayload)
|
||||
);
|
||||
assertEquals("Should return BAD_REQUEST", Response.Status.BAD_REQUEST.getStatusCode(),
|
||||
exception.getResponse().getStatus());
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user