Skip to content
Open
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 @@ -112,6 +112,8 @@
import org.keycloak.protocol.oid4vc.utils.ClaimsPathPointer;
import org.keycloak.protocol.oid4vc.utils.CredentialScopeUtils;
import org.keycloak.protocol.oid4vc.utils.OID4VCUtil;
import org.keycloak.protocol.oidc.encode.AccessTokenContext;
import org.keycloak.protocol.oidc.encode.TokenContextEncoderProvider;
import org.keycloak.protocol.oidc.grants.PreAuthorizedCodeGrantType;
import org.keycloak.protocol.oidc.rar.AuthorizationDetailsProcessor;
import org.keycloak.representations.AccessToken;
Expand Down Expand Up @@ -232,10 +234,24 @@ public OID4VCIssuerEndpoint(KeycloakSession keycloakSession) {

this.credentialBuilders = loadCredentialBuilders(session);

RealmModel realm = keycloakSession.getContext().getRealm();
this.credentialOfferLifespan = Optional.ofNullable(realm.getAttribute(CREDENTIAL_OFFER_LIFESPAN_REALM_ATTRIBUTE_KEY))
.map(Integer::valueOf)
.orElse(DEFAULT_CREDENTIAL_OFFER_LIFESPAN_S);
this.credentialOfferLifespan = getCredentialOfferLifespan(keycloakSession.getContext().getRealm());
}

/**
* Returns the configured credential-offer lifespan, falling back to the default for missing or malformed values.
*/
public static int getCredentialOfferLifespan(RealmModel realm) {
String configuredLifespan = realm.getAttribute(CREDENTIAL_OFFER_LIFESPAN_REALM_ATTRIBUTE_KEY);
if (configuredLifespan == null) {
return DEFAULT_CREDENTIAL_OFFER_LIFESPAN_S;
}
try {
return Integer.parseInt(configuredLifespan);
} catch (NumberFormatException e) {
LOGGER.warnf("Invalid credential offer lifespan '%s' configured for realm '%s'; using default of %d seconds",
configuredLifespan, realm.getName(), DEFAULT_CREDENTIAL_OFFER_LIFESPAN_S);
return DEFAULT_CREDENTIAL_OFFER_LIFESPAN_S;
}
}

/**
Expand Down Expand Up @@ -480,7 +496,7 @@ public Response createCredentialOffer(
@QueryParam("credential_configuration_id") String credentialConfigurationId,
@QueryParam("pre_authorized") @DefaultValue("false") Boolean preAuthorized,
@QueryParam("target_user") String targetUser,
@QueryParam("expire") Integer expiresAt,
@QueryParam("expire") Long expiresAt,
@QueryParam("type") @DefaultValue("uri") OfferResponseType responseType,
@QueryParam("width") @DefaultValue("200") int width,
@QueryParam("height") @DefaultValue("200") int height
Expand Down Expand Up @@ -534,8 +550,17 @@ public Response createCredentialOffer(
targetUser = loginUserModel.getUsername();
}

long currentTime = timeProvider.currentTimeSeconds();
long maximumExpiresAt = currentTime + credentialOfferLifespan;
Comment thread
forkimenjeckayang marked this conversation as resolved.
if (expiresAt == null) {
expiresAt = timeProvider.currentTimeSeconds() + credentialOfferLifespan;
expiresAt = maximumExpiresAt;
}
if (expiresAt <= currentTime || expiresAt > maximumExpiresAt) {
var errorMessage = String.format(
"Credential offer expiration must be after the current time and no later than %d", maximumExpiresAt);
eventBuilder.detail(Details.REASON, errorMessage).error(ErrorType.INVALID_REQUEST.getValue());
throw new CorsErrorResponseException(cors,
ErrorType.INVALID_CREDENTIAL_OFFER_REQUEST.getValue(), errorMessage, Response.Status.BAD_REQUEST);
}

// Create the CredentialsOffer
Expand All @@ -550,6 +575,30 @@ public Response createCredentialOffer(
CredentialOfferProvider offerProvider = session.getProvider(CredentialOfferProvider.class);
offerState = offerProvider.createCredentialOffer(loginUserModel, grantType,
credentialConfigurationIds, targetClientId, targetUser, expiresAt);
if (preAuthorized) {
offerState.setOriginatingUserId(loginUserModel.getId());
// Bearer authentication reconstructs request-scoped sessions for stateless tokens. Bind the offer to
// a session only when the access token identifies a persistent online or offline origin.
AccessTokenContext.SessionType originatingSessionType = session
.getProvider(TokenContextEncoderProvider.class)
.getTokenContextFromTokenId(getAuthResult().token().getId())
.getSessionType();
boolean transientUserSession = originatingSessionType == AccessTokenContext.SessionType.TRANSIENT;
boolean originatingSessionOffline = switch (originatingSessionType) {
case OFFLINE, OFFLINE_TRANSIENT_CLIENT -> true;
case UNKNOWN -> userSession.isOffline();
default -> false;
};
boolean bindOriginatingSession = !transientUserSession
&& (originatingSessionType != AccessTokenContext.SessionType.UNKNOWN
|| loginUserModel.getServiceAccountClientLink() == null);
if (bindOriginatingSession) {
offerState.setOriginatingUserSessionId(userSession.getId());
offerState.setOriginatingUserSessionOffline(originatingSessionOffline);
}
offerState.setOriginatingUserPasswordCredentialCreatedDate(
OID4VCUtil.getPasswordCredentialTimestamp(loginUserModel));
}

} catch (CredentialOfferException ex) {
eventBuilder.detail(Details.REASON, ex.getMessage()).error(ex.getErrorType());
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,8 @@
public class OffsetTimeProvider implements TimeProvider {

@Override
public int currentTimeSeconds() {
return Time.currentTime();
public long currentTimeSeconds() {
return Time.currentTimeSeconds();
}

@Override
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ public interface TimeProvider {
*
* @return see description
*/
int currentTimeSeconds();
long currentTimeSeconds();

/**
* Returns current time in milliseconds
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,9 @@

import java.util.List;

import org.keycloak.events.Errors;
import org.keycloak.models.UserModel;
import org.keycloak.protocol.oid4vc.issuance.CredentialOfferException;
import org.keycloak.provider.Provider;

/**
Expand Down Expand Up @@ -84,6 +86,28 @@ CredentialOfferState createCredentialOffer(
Integer expireAt
);

/**
* Creates a credential offer with a {@code long} expiration timestamp.
* <p>
* The overload preserves compatibility with providers implementing the original {@link Integer}-based SPI. Providers
* that need to support expiration timestamps beyond the integer range can override this method directly.
*/
default CredentialOfferState createCredentialOffer(
UserModel user,
String grantType,
List<String> credentialConfigurationIds,
String targetClientId,
String targetUserId,
long expireAt
) {
if (expireAt < Integer.MIN_VALUE || expireAt > Integer.MAX_VALUE) {
throw new CredentialOfferException(Errors.INVALID_REQUEST,
"Credential offer expiration is outside the integer range supported by this provider: " + expireAt);
}
return createCredentialOffer(user, grantType, credentialConfigurationIds, targetClientId, targetUserId,
Integer.valueOf((int) expireAt));
}
Comment thread
forkimenjeckayang marked this conversation as resolved.

@Override
default void close() {
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,8 +37,13 @@ public class CredentialOfferState {
private CredentialsOffer credentialsOffer;
private String targetClientId;
private String targetUserId;
private String originatingUserId;
private String originatingUserSessionId;
private Boolean originatingUserSessionOffline;
private Long originatingUserPasswordCredentialCreatedDate;
private String nonce;
private String txCode;
private Long createdAt;
private long expiresAt;
private List<OID4VCAuthorizationDetail> authDetails;

Expand All @@ -65,6 +70,7 @@ public CredentialOfferState(
this.credentialsOffer = credOffer;
this.targetClientId = clientId;
this.targetUserId = userId;
this.createdAt = Time.currentTimeSeconds();
this.expiresAt = expiresAt;
String nonceSecret = Base64Url.encode(RandomSecret.createRandomSecret(64));
this.nonce = CredentialOfferLookupKey.embed(nonceSecret, credentialsOfferId);
Expand Down Expand Up @@ -94,6 +100,38 @@ public String getTargetUserId() {
return targetUserId;
}

public String getOriginatingUserId() {
return originatingUserId;
}

public void setOriginatingUserId(String originatingUserId) {
this.originatingUserId = originatingUserId;
}

public String getOriginatingUserSessionId() {
return originatingUserSessionId;
}

public void setOriginatingUserSessionId(String originatingUserSessionId) {
this.originatingUserSessionId = originatingUserSessionId;
}

public Boolean getOriginatingUserSessionOffline() {
return originatingUserSessionOffline;
}

public void setOriginatingUserSessionOffline(Boolean originatingUserSessionOffline) {
this.originatingUserSessionOffline = originatingUserSessionOffline;
}

public Long getOriginatingUserPasswordCredentialCreatedDate() {
return originatingUserPasswordCredentialCreatedDate;
}

public void setOriginatingUserPasswordCredentialCreatedDate(Long originatingUserPasswordCredentialCreatedDate) {
this.originatingUserPasswordCredentialCreatedDate = originatingUserPasswordCredentialCreatedDate;
}

public String getNonce() {
return nonce;
}
Expand All @@ -102,6 +140,10 @@ public String getTxCode() {
return txCode;
}

public Long getCreatedAt() {
return createdAt;
}

public long getExpiresAt() {
return expiresAt;
}
Expand Down Expand Up @@ -142,7 +184,7 @@ public boolean matchAuthorizationDetails(List<OID4VCAuthorizationDetail> otherAu

@JsonIgnore
public boolean isExpired() {
int currentTime = Time.currentTime();
long currentTime = Time.currentTimeSeconds();
return expiresAt <= currentTime;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,18 @@ public CredentialOfferState createCredentialOffer(
String targetClientId,
String targetUsername,
Integer expireAt) {
return createCredentialOffer(user, grantType, credentialConfigurationIds, targetClientId, targetUsername,
expireAt.longValue());
}
Comment thread
forkimenjeckayang marked this conversation as resolved.

@Override
public CredentialOfferState createCredentialOffer(
UserModel user,
String grantType,
List<String> credentialConfigurationIds,
String targetClientId,
String targetUsername,
long expireAt) {

// Checks whether `--feature=oid4vc_vci_preauth_code` is enabled
//
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ class DefaultCredentialOfferStorage implements CredentialOfferStorage {
* @return Lifespan in seconds, or 0 if the entry is already expired
*/
private long calculateLifespanSeconds(long expiresAt) {
long currentTime = Time.currentTime();
long currentTime = Time.currentTimeSeconds();
long lifespan = expiresAt - currentTime;

// If already expired or about to expire immediately, skip storage
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,7 @@ private void verifyPreAuthCodeClaims(JwtPreAuthCode jwtPayload) throws Verificat
throw new VerificationException("Jwt pre-auth code has no expiration time");
}

long now = Time.currentTime();
long now = Time.currentTimeSeconds();
if (exp < now) {
String message = String.format("Jwt pre-auth code not valid: %s (exp) < %s (now)", exp, now);
throw new VerificationException(message, Errors.EXPIRED_CODE);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@

import java.io.IOException;
import java.util.List;
import java.util.Optional;

import jakarta.ws.rs.core.Response;

Expand Down Expand Up @@ -39,8 +38,7 @@
import static org.keycloak.constants.OID4VCIConstants.IS_ADMIN_INITIATED;
import static org.keycloak.constants.OID4VCIConstants.VERIFIABLE_CREDENTIAL_OFFER_PROVIDER_ID;
import static org.keycloak.events.Details.REASON;
import static org.keycloak.protocol.oid4vc.issuance.OID4VCIssuerEndpoint.CREDENTIAL_OFFER_LIFESPAN_REALM_ATTRIBUTE_KEY;
import static org.keycloak.protocol.oid4vc.issuance.OID4VCIssuerEndpoint.DEFAULT_CREDENTIAL_OFFER_LIFESPAN_S;
import static org.keycloak.protocol.oid4vc.issuance.OID4VCIssuerEndpoint.getCredentialOfferLifespan;
import static org.keycloak.protocol.oid4vc.model.AuthorizationCodeGrant.AUTH_CODE_GRANT_TYPE;
import static org.keycloak.protocol.oid4vc.model.ErrorType.INVALID_CREDENTIAL_OFFER_REQUEST;
import static org.keycloak.protocol.oid4vc.model.ErrorType.MISSING_CREDENTIAL_CONFIG;
Expand Down Expand Up @@ -166,10 +164,8 @@ private CredentialOfferState createCredentialsOffer(KeycloakSession session, Rea
VerifiableCredentialOfferActionConfig actionConfig) throws CredentialOfferException {
boolean preAuthorized = actionConfig.getPreAuthorized() != null && actionConfig.getPreAuthorized();
String grantType = preAuthorized ? PRE_AUTH_GRANT_TYPE : AUTH_CODE_GRANT_TYPE;
int credentialOfferLifespan = Optional.ofNullable(realm.getAttribute(CREDENTIAL_OFFER_LIFESPAN_REALM_ATTRIBUTE_KEY))
.map(Integer::valueOf)
.orElse(DEFAULT_CREDENTIAL_OFFER_LIFESPAN_S);
int expiresAt = timeProvider.currentTimeSeconds() + credentialOfferLifespan;
int credentialOfferLifespan = getCredentialOfferLifespan(realm);
long expiresAt = timeProvider.currentTimeSeconds() + credentialOfferLifespan;

String credentialConfigurationId = actionConfig.getCredentialConfigurationId();
event = event.clone().detail(Details.CREDENTIAL_TYPE, credentialConfigurationId);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.UserModel;
import org.keycloak.models.UserVerifiableCredentialModel;
import org.keycloak.models.credential.PasswordCredentialModel;
import org.keycloak.models.oid4vci.CredentialScopeModel;
import org.keycloak.protocol.oid4vc.OID4VCLoginProtocolFactory;
import org.keycloak.protocol.oid4vc.issuance.OID4VCIssuerWellKnownProvider;
Expand Down Expand Up @@ -46,6 +47,23 @@ public static boolean hasVerifiableCredential(KeycloakSession session, UserModel
.anyMatch(credential -> credential.getClientScopeId().equals(credentialScope.getId()));
}

/**
* Returns the timestamp exposed by the user's password credential.
* <p>
* The combined credential stream is important for federated users because providers such as LDAP expose their
* password modification timestamp as federated credential metadata rather than as a locally stored credential.
*
* @return the credential timestamp, {@code 0} when the credential has no timestamp, or {@code -1} when no password
* credential metadata is available
*/
public static long getPasswordCredentialTimestamp(UserModel user) {
return user.credentialManager().getCredentials()
.filter(credential -> PasswordCredentialModel.TYPE.equals(credential.getType()))
.mapToLong(credential -> Optional.ofNullable(credential.getCreatedDate()).orElse(0L))
.max()
.orElse(-1L);
}

/**
* Check issued-credential present on the user with expected ID and expected issued-credential-id and credential-scope
*
Expand Down
Loading
Loading