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
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ public interface Details {
String TOKEN_ISSUED_FOR = "token_issued_for";
String ORG_ID = "org_id";
String REFRESH_TOKEN_ID = "refresh_token_id";
String REFRESH_TOKEN_PROVIDER_ID = "refresh_token_provider_id";
String REFRESH_TOKEN_TYPE = "refresh_token_type";
String REFRESH_TOKEN_SUB = "refresh_token_sub";
String CLIENT_ASSERTION_ID = "client_assertion_id";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -234,6 +234,11 @@ public void revokeToken(AccessToken token, UserModel user, ClientModel client, E
}
}

@Override
public String getProviderId() {
return OID4VCIRefreshTokenProviderFactory.PROVIDER_ID;
}

// Might eventually be overridden for scenarios where a user is not available in the Keycloak DB
protected UserModel getUser(RealmModel realm, RefreshToken oldToken) {
String userId = oldToken.getSubject();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1290,6 +1290,9 @@ private void generateRefreshToken(boolean offlineTokenRequested) {

refreshToken = refreshTokenProvider.generateRefreshToken(initialRefreshTokenContext);

String providerId = refreshToken.getProvider() != null ? refreshToken.getProvider() : refreshTokenProvider.getProviderId();
event.detail(Details.REFRESH_TOKEN_PROVIDER_ID, providerId);
Comment on lines +1293 to +1294

Boolean bindOnlyRefreshToken = session.getAttributeOrDefault(DPoPUtil.DPOP_BINDING_ONLY_REFRESH_TOKEN_SESSION_ATTRIBUTE, false);
if (bindOnlyRefreshToken) {
DPoP dPoP = session.getAttribute(DPoPUtil.DPOP_SESSION_ATTRIBUTE, DPoP.class);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,7 @@ public Response revoke() {

refreshTokenProvider.revokeToken(token, user, client, event);

event.detail(Details.REFRESH_TOKEN_PROVIDER_ID, providerClaim != null ? providerClaim : refreshTokenProvider.getProviderId());
event.detail(Details.REVOKED_CLIENT, client.getClientId());
event.session(token.getSessionId());
event.detail(Details.REFRESH_TOKEN_ID, token.getId());
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,9 @@ public TokenManager.AccessTokenResponseBuilder refreshAccessToken(RefreshTokenCo
.detail(Details.REFRESH_TOKEN_ID, oldRefreshToken.getId())
.detail(Details.REFRESH_TOKEN_TYPE, oldRefreshToken.getType());

String providerIdDetail = oldRefreshToken.getProvider() != null ? oldRefreshToken.getProvider() : getProviderId();
event.detail(Details.REFRESH_TOKEN_PROVIDER_ID, providerIdDetail);
Comment on lines +79 to +80

if (oldRefreshToken.getSubject() != null) {
event.detail(Details.REFRESH_TOKEN_SUB, oldRefreshToken.getSubject());
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,15 @@ default void revokeToken(AccessToken token, UserModel user, ClientModel client,
// no-op by default
}

/**
* Returns the provider ID that this instance uses to identify issued refresh tokens.
* Used for event detail emission during initial issuance
* and refresh-token processing when the token does not carry a provider claim
*/
default String getProviderId() {
return DefaultRefreshTokenProviderFactory.PROVIDER_ID;
}

@Override
default void close() {
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
package org.keycloak.tests.oid4vc;

import java.util.List;

import org.keycloak.events.Details;
import org.keycloak.events.EventType;
import org.keycloak.protocol.oid4vc.refresh.OID4VCIRefreshTokenProviderFactory;
import org.keycloak.protocol.oidc.refresh.DefaultRefreshTokenProviderFactory;
import org.keycloak.testframework.annotations.InjectUser;
import org.keycloak.testframework.annotations.KeycloakIntegrationTest;
import org.keycloak.testframework.annotations.TestSetup;
import org.keycloak.testframework.events.EventAssertion;
import org.keycloak.testframework.realm.ManagedUser;
import org.keycloak.testsuite.util.oauth.AccessTokenResponse;
import org.keycloak.testsuite.util.oauth.AuthorizationEndpointResponse;

import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;

import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;

@KeycloakIntegrationTest(config = OID4VCIssuerTestBase.VCTestServerConfig.class)
public class OID4VCRefreshTokenProviderEventTest extends OID4VCIssuerTestBase {

@InjectUser(config = OID4VCActionTest.OID4VCTestUserConfig.class)
ManagedUser user;

OID4VCTestContext ctx;

@TestSetup
public void configureTestRealm() {
super.configureTestRealm();
var realmRep = testRealm.admin().toRepresentation();
realmRep.setEnabledEventTypes(List.of(
EventType.CODE_TO_TOKEN.toString(),
EventType.REVOKE_GRANT.toString(),
EventType.REFRESH_TOKEN.toString()));
testRealm.admin().update(realmRep);
}

@BeforeEach
void beforeEach() {
ctx = new OID4VCTestContext(client, minimalJwtTypeCredentialScope);
user.admin().logout();
user.admin().verifiableCredentials().getIssuedCredentials()
.forEach(issuedCred -> user.admin().verifiableCredentials().revokeIssuedCredential(issuedCred.getId()));
events.clear();
}

/**
* Verify that the REFRESH_TOKEN event carries Details.REFRESH_TOKEN_PROVIDER_ID with value "oid4vci"
* when the token was issued by OID4VCIRefreshTokenProvider.
*/
@Test
public void testRefreshTokenEventCarriesOid4vciProviderId() {
AccessTokenResponse tokenResponse = authzCodeFlow();
assertTrue(tokenResponse.isSuccess(), "Access token exchange should succeed");

String accessToken = tokenResponse.getAccessToken();
String credentialIdentifier = ctx.getAuthorizedCredentialIdentifier();

wallet.credentialRequest(ctx, accessToken)
.credentialIdentifier(credentialIdentifier)
.send().getCredentialResponse();

timeOffSet.set(5);

AccessTokenResponse refreshResponse = wallet.refreshRequest(ctx).send();
assertTrue(refreshResponse.isSuccess(), "Refresh must succeed");

EventAssertion.assertSuccess(events.poll())
.type(EventType.REFRESH_TOKEN)
.details(Details.REFRESH_TOKEN_PROVIDER_ID,
OID4VCIRefreshTokenProviderFactory.PROVIDER_ID);
}

/**
* A standard token must carry provider id "default"
* when the token was issued by DefaultRefreshTokenProvider.
*/
@Test
public void testStandardRefreshTokenEventCarriesDefaultProviderId() {
// Perform a plain OIDC password grant flow (no OID4VCI scope)
AccessTokenResponse tokenResponse = oauth.doPasswordGrantRequest(user.getUsername(), user.getPassword());
assertTrue(tokenResponse.isSuccess(), "Access token exchange should succeed");

String refreshToken = tokenResponse.getRefreshToken();
AccessTokenResponse refreshResponse = oauth.doRefreshTokenRequest(refreshToken);
assertTrue(refreshResponse.isSuccess(), "Standard refresh must succeed");

EventAssertion.assertSuccess(events.poll())
.type(EventType.REFRESH_TOKEN)
.details(Details.REFRESH_TOKEN_PROVIDER_ID,
DefaultRefreshTokenProviderFactory.PROVIDER_ID);
}

/**
* Verify that Revoke grant event must carry
* Details.REFRESH_TOKEN_PROVIDER_ID with value "oid4vci"
*/
@Test
public void testRevokeGrantEventCarriesOid4vciProviderId() {
ctx = new OID4VCTestContext(client, minimalJwtTypeCredentialScope);
user.admin().logout();

AccessTokenResponse tokenResponse = authzCodeFlow();
assertTrue(tokenResponse.isSuccess(), "Access token exchange should succeed");

EventAssertion.assertSuccess(events.poll())
.type(EventType.CODE_TO_TOKEN)
.details(Details.REFRESH_TOKEN_PROVIDER_ID,
OID4VCIRefreshTokenProviderFactory.PROVIDER_ID);

String accessToken = tokenResponse.getAccessToken();
String credentialIdentifier = ctx.getAuthorizedCredentialIdentifier();

wallet.credentialRequest(ctx, accessToken)
.credentialIdentifier(credentialIdentifier)
.send().getCredentialResponse();

String refreshToken = tokenResponse.getRefreshToken();
oauth.doTokenRevoke(refreshToken);

EventAssertion.assertSuccess(events.poll())
.type(EventType.REVOKE_GRANT)
.details(Details.REFRESH_TOKEN_PROVIDER_ID,
OID4VCIRefreshTokenProviderFactory.PROVIDER_ID);
Comment on lines +122 to +128
}

/**
* CODE exchange event must carry refresh_token_provider_id = "oid4vci"
*/
@Test
public void testAuthzCodeExchangeEventCarriesOid4vciProviderId() {
ctx = new OID4VCTestContext(client, minimalJwtTypeCredentialScope);
user.admin().logout();

AccessTokenResponse tokenResponse = authzCodeFlow();
assertTrue(tokenResponse.isSuccess());

EventAssertion.assertSuccess(events.poll())
.type(EventType.CODE_TO_TOKEN)
.details(Details.REFRESH_TOKEN_PROVIDER_ID,
OID4VCIRefreshTokenProviderFactory.PROVIDER_ID);
}

private AccessTokenResponse authzCodeFlow() {
AuthorizationEndpointResponse authResponse = wallet.authorizationRequest()
.scope(ctx.getScope())
.send(user.getUsername(), TEST_PASSWORD);
assertTrue(authResponse.isSuccess());

AccessTokenResponse tokenResponse = wallet.accessTokenRequest(ctx, authResponse.getCode()).send();
assertNotNull(tokenResponse, "Token response should not be null");

String accessToken = wallet.validateHolderAccessToken(ctx, tokenResponse);
assertNotNull(accessToken, "No accessToken");
return tokenResponse;
}
Comment thread
jimmychakkalakal marked this conversation as resolved.
}
Loading