Skip to content

Commit d815854

Browse files
Add support for parsing and understanding authorization_details at the Token Endpoint
Closes: keycloak#39278 Closses: keycloak#39279 Signed-off-by: forkimenjeckayang <[email protected]>
1 parent fc3914c commit d815854

File tree

19 files changed

+1177
-20
lines changed

19 files changed

+1177
-20
lines changed

core/src/main/java/org/keycloak/OAuth2Constants.java

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,8 @@ public interface OAuth2Constants {
160160

161161
String CNF = "cnf";
162162

163+
String AUTHORIZATION_DETAILS_PARAM = "authorization_details";
164+
163165
// DPoP - https://datatracker.ietf.org/doc/html/rfc9449
164166
String DPOP_HTTP_HEADER = "DPoP";
165167
Algorithm DPOP_DEFAULT_ALGORITHM = PS256;

server-spi-private/src/main/java/org/keycloak/protocol/oidc/grants/OAuth2GrantType.java

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@
3737
import org.keycloak.models.RealmModel;
3838
import org.keycloak.provider.Provider;
3939
import org.keycloak.services.cors.Cors;
40+
import org.keycloak.protocol.LoginProtocol;
4041

4142
/**
4243
* Provider interface for OAuth 2.0 grant types
@@ -83,6 +84,7 @@ public static class Context {
8384
protected Cors cors;
8485
protected Object tokenManager;
8586
protected String grantType;
87+
protected LoginProtocol protocol;
8688

8789
public Context(KeycloakSession session, Object clientConfig, Map<String, String> clientAuthAttributes,
8890
MultivaluedMap<String, String> formParams, EventBuilder event, Cors cors, Object tokenManager) {
@@ -118,6 +120,10 @@ public void setClientAuthAttributes(Map<String, String> clientAuthAttributes) {
118120
this.clientAuthAttributes = clientAuthAttributes;
119121
}
120122

123+
public void setProtocol(LoginProtocol protocol) {
124+
this.protocol = protocol;
125+
}
126+
121127
public ClientModel getClient() {
122128
return client;
123129
}
@@ -173,6 +179,10 @@ public Object getTokenManager() {
173179
public String getGrantType() {
174180
return grantType;
175181
}
182+
183+
public LoginProtocol getProtocol() {
184+
return protocol;
185+
}
176186
}
177187

178188
}
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
/*
2+
* Copyright 2025 Red Hat, Inc. and/or its affiliates
3+
* and other contributors as indicated by the @author tags.
4+
*
5+
* Licensed under the Apache License, Version 2.0 (the "License");
6+
* you may not use this file except in compliance with the License.
7+
* You may obtain a copy of the License at
8+
*
9+
* http://www.apache.org/licenses/LICENSE-2.0
10+
*
11+
* Unless required by applicable law or agreed to in writing, software
12+
* distributed under the License is distributed on an "AS IS" BASIS,
13+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
* See the License for the specific language governing permissions and
15+
* limitations under the License.
16+
*/
17+
18+
package org.keycloak.protocol.oid4vc.issuance;
19+
20+
import org.keycloak.models.UserSessionModel;
21+
import org.keycloak.models.ClientSessionContext;
22+
import org.keycloak.protocol.oid4vc.model.AuthorizationDetailResponse;
23+
24+
import java.util.List;
25+
26+
/**
27+
* Processor for authorization details in OID4VC issuance flow.
28+
*
29+
* @author <a href="mailto:[email protected]">Forkim Akwichek</a>
30+
*/
31+
public interface AuthorizationDetailsProcessor {
32+
List<AuthorizationDetailResponse> process(UserSessionModel userSession, ClientSessionContext clientSessionCtx);
33+
}
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
/*
2+
* Copyright 2025 Red Hat, Inc. and/or its affiliates
3+
* and other contributors as indicated by the @author tags.
4+
*
5+
* Licensed under the Apache License, Version 2.0 (the "License");
6+
* you may not use this file except in compliance with the License.
7+
* You may obtain a copy of the License at
8+
*
9+
* http://www.apache.org/licenses/LICENSE-2.0
10+
*
11+
* Unless required by applicable law or agreed to in writing, software
12+
* distributed under the License is distributed on an "AS IS" BASIS,
13+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
* See the License for the specific language governing permissions and
15+
* limitations under the License.
16+
*/
17+
18+
package org.keycloak.protocol.oid4vc.issuance;
19+
20+
import org.keycloak.models.UserSessionModel;
21+
import org.keycloak.models.ClientSessionContext;
22+
import org.keycloak.protocol.oid4vc.model.AuthorizationDetailResponse;
23+
24+
import java.util.List;
25+
import java.util.Collections;
26+
27+
/**
28+
* Default implementation of {@link AuthorizationDetailsProcessor} that returns an empty list.
29+
* This is a no-op implementation for standard RAR (Rich Authorization Requests).
30+
*
31+
* @author <a href="mailto:[email protected]">Forkim Akwichek</a>
32+
*/
33+
public class DefaultAuthorizationDetailsProcessor implements AuthorizationDetailsProcessor {
34+
@Override
35+
public List<AuthorizationDetailResponse> process(UserSessionModel userSession, ClientSessionContext clientSessionCtx) {
36+
// No-op for standard RAR (Rich Authorization Requests) for now
37+
return Collections.emptyList();
38+
}
39+
}
Lines changed: 198 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,198 @@
1+
/*
2+
* Copyright 2025 Red Hat, Inc. and/or its affiliates
3+
* and other contributors as indicated by the @author tags.
4+
*
5+
* Licensed under the Apache License, Version 2.0 (the "License");
6+
* you may not use this file except in compliance with the License.
7+
* You may obtain a copy of the License at
8+
*
9+
* http://www.apache.org/licenses/LICENSE-2.0
10+
*
11+
* Unless required by applicable law or agreed to in writing, software
12+
* distributed under the License is distributed on an "AS IS" BASIS,
13+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
* See the License for the specific language governing permissions and
15+
* limitations under the License.
16+
*/
17+
18+
package org.keycloak.protocol.oid4vc.issuance;
19+
20+
import com.fasterxml.jackson.core.type.TypeReference;
21+
import jakarta.ws.rs.core.MultivaluedMap;
22+
import jakarta.ws.rs.core.Response;
23+
import org.jboss.logging.Logger;
24+
import org.keycloak.events.Errors;
25+
import org.keycloak.events.EventBuilder;
26+
import org.keycloak.models.ClientSessionContext;
27+
import org.keycloak.models.KeycloakSession;
28+
import org.keycloak.models.UserSessionModel;
29+
import org.keycloak.protocol.oid4vc.model.AuthorizationDetail;
30+
import org.keycloak.protocol.oid4vc.model.AuthorizationDetailResponse;
31+
import org.keycloak.protocol.oid4vc.model.CredentialIssuer;
32+
import org.keycloak.protocol.oid4vc.model.SupportedCredentialConfiguration;
33+
import org.keycloak.services.CorsErrorResponseException;
34+
import org.keycloak.services.cors.Cors;
35+
import org.keycloak.util.JsonSerialization;
36+
import org.keycloak.protocol.oid4vc.model.Format;
37+
38+
import static org.keycloak.protocol.oid4vc.model.Format.SUPPORTED_FORMATS;
39+
import static org.keycloak.OAuth2Constants.AUTHORIZATION_DETAILS_PARAM;
40+
41+
import java.util.ArrayList;
42+
import java.util.List;
43+
import java.util.Map;
44+
import java.util.UUID;
45+
46+
public class OID4VCAuthorizationDetailsProcessor implements AuthorizationDetailsProcessor {
47+
private static final Logger logger = Logger.getLogger(OID4VCAuthorizationDetailsProcessor.class);
48+
private final KeycloakSession session;
49+
private final EventBuilder event;
50+
private final MultivaluedMap<String, String> formParams;
51+
private final Cors cors;
52+
53+
public static final String OPENID_CREDENTIAL_TYPE = "openid_credential";
54+
public static final String AUTHORIZATION_DETAILS_RESPONSE_KEY = "authorization_details_response";
55+
56+
public OID4VCAuthorizationDetailsProcessor(KeycloakSession session, EventBuilder event, MultivaluedMap<String, String> formParams, Cors cors) {
57+
this.session = session;
58+
this.event = event;
59+
this.formParams = formParams;
60+
this.cors = cors;
61+
}
62+
63+
public List<AuthorizationDetailResponse> process(UserSessionModel userSession, ClientSessionContext clientSessionCtx) {
64+
String authorizationDetailsParam = formParams.getFirst(AUTHORIZATION_DETAILS_PARAM);
65+
if (authorizationDetailsParam == null) {
66+
return null; // authorization_details is optional
67+
}
68+
69+
List<AuthorizationDetail> authDetails = parseAuthorizationDetails(authorizationDetailsParam);
70+
List<String> supportedFormats = new ArrayList<>(SUPPORTED_FORMATS);
71+
Map<String, SupportedCredentialConfiguration> supportedCredentials = OID4VCIssuerWellKnownProvider.getSupportedCredentials(session);
72+
List<AuthorizationDetailResponse> authDetailsResponse = new ArrayList<>();
73+
74+
// Retrieve authorization servers and issuer identifier for locations check
75+
List<String> authorizationServers = OID4VCIssuerWellKnownProvider.getAuthorizationServers(session);
76+
String issuerIdentifier = OID4VCIssuerWellKnownProvider.getIssuer(session.getContext());
77+
78+
for (AuthorizationDetail detail : authDetails) {
79+
validateAuthorizationDetail(detail, supportedFormats, supportedCredentials, authorizationServers, issuerIdentifier);
80+
AuthorizationDetailResponse responseDetail = buildAuthorizationDetailResponse(detail, userSession, supportedCredentials, supportedFormats, clientSessionCtx);
81+
authDetailsResponse.add(responseDetail);
82+
}
83+
84+
return authDetailsResponse;
85+
}
86+
87+
private List<AuthorizationDetail> parseAuthorizationDetails(String authorizationDetailsParam) {
88+
try {
89+
return JsonSerialization.readValue(authorizationDetailsParam, new TypeReference<List<AuthorizationDetail>>() {
90+
});
91+
} catch (Exception e) {
92+
logger.warnf(e, "Invalid authorization_details format: %s", authorizationDetailsParam);
93+
throw getInvalidRequestException("Invalid authorization_details format: " + authorizationDetailsParam);
94+
}
95+
}
96+
97+
private RuntimeException getInvalidRequestException(String errorDescription) {
98+
event.error(Errors.INVALID_REQUEST);
99+
return new CorsErrorResponseException(cors, "invalid_request", errorDescription, Response.Status.BAD_REQUEST);
100+
}
101+
102+
private void validateAuthorizationDetail(AuthorizationDetail detail, List<String> supportedFormats, Map<String, SupportedCredentialConfiguration> supportedCredentials, List<String> authorizationServers, String issuerIdentifier) {
103+
String type = detail.getType();
104+
String credentialConfigurationId = detail.getCredentialConfigurationId();
105+
String format = detail.getFormat();
106+
Object vct = detail.getAdditionalFields().get("vct");
107+
108+
// If authorization_servers is present, locations must be set to issuer identifier
109+
if (authorizationServers != null && !authorizationServers.isEmpty() && OPENID_CREDENTIAL_TYPE.equals(type)) {
110+
List<String> locations = detail.getLocations();
111+
if (locations == null || locations.size() != 1 || !issuerIdentifier.equals(locations.get(0))) {
112+
logger.warnf("Invalid locations field in authorization_details: %s, expected: %s", locations, issuerIdentifier);
113+
throw getInvalidRequestException("Invalid authorization_details: locations=" + locations + ", expected=" + issuerIdentifier);
114+
}
115+
}
116+
117+
// Validate type
118+
if (!OPENID_CREDENTIAL_TYPE.equals(type)) {
119+
logger.warnf("Invalid authorization_details type: %s", type);
120+
throw getInvalidRequestException("Invalid authorization_details type: " + type + ", expected=" + OPENID_CREDENTIAL_TYPE);
121+
}
122+
123+
// Ensure exactly one of credential_configuration_id or format is present
124+
if ((credentialConfigurationId == null && format == null) || (credentialConfigurationId != null && format != null)) {
125+
logger.warnf("Exactly one of credential_configuration_id or format must be present. credentialConfigurationId: %s, format: %s", credentialConfigurationId, format);
126+
throw getInvalidRequestException("Invalid authorization_details: credentialConfigurationId=" + credentialConfigurationId + ", format=" + format + ". Exactly one must be present.");
127+
}
128+
129+
if (credentialConfigurationId != null) {
130+
// Validate credential_configuration_id
131+
SupportedCredentialConfiguration config = supportedCredentials.get(credentialConfigurationId);
132+
if (config == null) {
133+
logger.warnf("Unsupported credential_configuration_id: %s", credentialConfigurationId);
134+
throw getInvalidRequestException("Invalid credential configuration: unsupported credential_configuration_id=" + credentialConfigurationId);
135+
}
136+
} else {
137+
// Validate format
138+
if (!supportedFormats.contains(format)) {
139+
logger.warnf("Unsupported format: %s", format);
140+
throw getInvalidRequestException("Invalid credential format: unsupported format=" + format + ", supported=" + supportedFormats);
141+
}
142+
143+
// SD-JWT VC: vct is REQUIRED and must match a supported credential configuration
144+
if (Format.SD_JWT_VC.equals(format)) {
145+
if (!(vct instanceof String) || ((String) vct).isEmpty()) {
146+
logger.warnf("Missing or invalid vct for format %s", Format.SD_JWT_VC);
147+
throw getInvalidRequestException(String.format("Missing or invalid vct for format=%s", Format.SD_JWT_VC));
148+
}
149+
boolean vctSupported = supportedCredentials.values().stream()
150+
.filter(config -> format.equals(config.getFormat()))
151+
.anyMatch(config -> vct.equals(config.getVct()));
152+
if (!vctSupported) {
153+
logger.warnf("Unsupported vct for format %s: %s", format, vct);
154+
throw getInvalidRequestException("Invalid credential configuration: unsupported vct=" + vct + " for format=" + format);
155+
}
156+
} else {
157+
// For other formats, do not require vct; allow for future format-specific fields in additionalFields
158+
// No-op for now
159+
}
160+
}
161+
}
162+
163+
private AuthorizationDetailResponse buildAuthorizationDetailResponse(AuthorizationDetail detail, UserSessionModel userSession, Map<String, SupportedCredentialConfiguration> supportedCredentials, List<String> supportedFormats, ClientSessionContext clientSessionCtx) {
164+
String credentialConfigurationId = detail.getCredentialConfigurationId();
165+
String format = detail.getFormat();
166+
Object vct = detail.getAdditionalFields().get("vct");
167+
168+
// Try to reuse identifier from authorizationDetailsResponse in client session context
169+
List<AuthorizationDetailResponse> previousResponses = clientSessionCtx.getAttribute(AUTHORIZATION_DETAILS_RESPONSE_KEY, List.class);
170+
List<String> credentialIdentifiers = null;
171+
if (previousResponses != null) {
172+
for (AuthorizationDetailResponse prev : previousResponses) {
173+
if ((credentialConfigurationId != null && credentialConfigurationId.equals(prev.getCredentialConfigurationId())) ||
174+
(credentialConfigurationId == null && format != null && format.equals(prev.getFormat()))) {
175+
credentialIdentifiers = prev.getCredentialIdentifiers();
176+
break;
177+
}
178+
}
179+
}
180+
if (credentialIdentifiers == null) {
181+
credentialIdentifiers = new ArrayList<>();
182+
credentialIdentifiers.add(UUID.randomUUID().toString());
183+
}
184+
185+
AuthorizationDetailResponse responseDetail = new AuthorizationDetailResponse();
186+
responseDetail.setType(OPENID_CREDENTIAL_TYPE);
187+
responseDetail.setCredentialIdentifiers(credentialIdentifiers);
188+
if (credentialConfigurationId != null) {
189+
responseDetail.setCredentialConfigurationId(credentialConfigurationId);
190+
} else {
191+
responseDetail.setFormat(format);
192+
if (Format.SD_JWT_VC.equals(format) && vct != null) {
193+
responseDetail.getAdditionalFields().put("vct", vct);
194+
}
195+
}
196+
return responseDetail;
197+
}
198+
}

services/src/main/java/org/keycloak/protocol/oid4vc/issuance/OID4VCIssuerWellKnownProvider.java

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -254,4 +254,11 @@ public static List<String> getSupportedSignatureAlgorithms(KeycloakSession sessi
254254
.collect(Collectors.toList());
255255
}
256256

257+
/**
258+
* Return the authorization servers from the issuer configuration.
259+
*/
260+
public static List<String> getAuthorizationServers(KeycloakSession session) {
261+
return List.of(getIssuer(session.getContext()));
262+
}
263+
257264
}

0 commit comments

Comments
 (0)