20
20
import lombok .Getter ;
21
21
import org .gridsuite .gateway .GatewayService ;
22
22
import org .gridsuite .gateway .dto .TokenIntrospection ;
23
+ import org .gridsuite .gateway .services .UserAdminService ;
23
24
import org .gridsuite .gateway .services .UserIdentityService ;
24
25
import org .slf4j .Logger ;
25
26
import org .slf4j .LoggerFactory ;
39
40
import java .util .Map ;
40
41
import java .util .concurrent .ConcurrentHashMap ;
41
42
43
+ import static org .gridsuite .gateway .GatewayConfig .HEADER_ROLES ;
42
44
import static org .gridsuite .gateway .GatewayConfig .HEADER_USER_ID ;
43
45
44
- //TODO add client_id
45
- //import static org.gridsuite.gateway.GatewayConfig.HEADER_CLIENT_ID;
46
-
47
46
/**
48
47
* @author Chamseddine Benhamed <chamseddine.benhamed at rte-france.com>
49
48
*/
@@ -52,6 +51,8 @@ public class TokenValidatorGlobalPreFilter extends AbstractGlobalPreFilter {
52
51
53
52
public static final String UNAUTHORIZED_INVALID_PLAIN_JOSE_OBJECT_ENCODING = "{}: 401 Unauthorized, Invalid plain JOSE object encoding or inactive opaque token" ;
54
53
public static final String PARSING_ERROR = "{}: 500 Internal Server Error, error has been reached unexpectedly while parsing" ;
54
+ public static final String UNAUTHORIZED_AUDIENCE_NOT_ALLOWED = "401 Unauthorized, {} Audience is not in the audiences white list" ;
55
+ public static final String UNAUTHORIZED_CLIENT_NOT_ALLOWED = "401 Unauthorized, {} Client ID is not in the allowed clients list" ;
55
56
56
57
private static final Logger LOGGER = LoggerFactory .getLogger (TokenValidatorGlobalPreFilter .class );
57
58
public static final String UNAUTHORIZED_THE_TOKEN_CANNOT_BE_TRUSTED = "{}: 401 Unauthorized, The token cannot be trusted" ;
@@ -62,12 +63,19 @@ public class TokenValidatorGlobalPreFilter extends AbstractGlobalPreFilter {
62
63
@ Value ("${allowed-issuers}" )
63
64
private List <String > allowedIssuers ;
64
65
66
+ @ Value ("${allowed-audiences:}" )
67
+ private List <String > allowedAudiences ;
68
+
69
+ @ Value ("${allowed-clients:}" )
70
+ private List <String > allowedClients ;
71
+
65
72
@ Value ("${storeIdToken:false}" )
66
73
private boolean storeIdTokens ;
67
74
68
75
private Map <String , JWKSet > jwkSetCache = new ConcurrentHashMap <>();
69
76
70
- public TokenValidatorGlobalPreFilter (GatewayService gatewayService , UserIdentityService userIdentityService ) {
77
+ public TokenValidatorGlobalPreFilter (GatewayService gatewayService , UserIdentityService userIdentityService , UserAdminService userAdminService ) {
78
+ super (userAdminService );
71
79
this .gatewayService = gatewayService ;
72
80
this .userIdentityService = userIdentityService ;
73
81
}
@@ -88,7 +96,7 @@ public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
88
96
if (ls == null && queryls == null ) {
89
97
LOGGER .info ("{}: 401 Unauthorized, Authorization header or access_token query parameter is required" ,
90
98
exchange .getRequest ().getPath ());
91
- return completeWithCode (exchange , HttpStatus .UNAUTHORIZED );
99
+ return completeWithError (exchange , HttpStatus .UNAUTHORIZED );
92
100
}
93
101
94
102
// For now we only handle one token. If needed, we can adapt this code to check
@@ -101,7 +109,7 @@ public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
101
109
if (arr .size () != 2 || !arr .get (0 ).equals ("Bearer" )) {
102
110
LOGGER .info ("{}: 400 Bad Request, incorrect Authorization header value" ,
103
111
exchange .getRequest ().getPath ());
104
- return completeWithCode (exchange , HttpStatus .BAD_REQUEST );
112
+ return completeWithError (exchange , HttpStatus .BAD_REQUEST );
105
113
}
106
114
107
115
token = arr .get (1 );
@@ -118,11 +126,23 @@ public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
118
126
LOGGER .debug ("checking issuer" );
119
127
if (allowedIssuers .stream ().noneMatch (iss -> jwtClaimsSet .getIssuer ().startsWith (iss ))) {
120
128
LOGGER .info ("{}: 401 Unauthorized, {} Issuer is not in the issuers white list" , exchange .getRequest ().getPath (), jwtClaimsSet .getIssuer ());
121
- return completeWithCode (exchange , HttpStatus .UNAUTHORIZED );
129
+ return completeWithError (exchange , HttpStatus .UNAUTHORIZED );
130
+ }
131
+
132
+ if (!isValidAudienceOrClientId (jwtClaimsSet )) {
133
+ return completeWithError (exchange , HttpStatus .UNAUTHORIZED );
122
134
}
123
135
124
136
Issuer iss = new Issuer (jwtClaimsSet .getIssuer ());
125
- ClientID clientID = new ClientID (jwtClaimsSet .getAudience ().get (0 ));
137
+ ClientID clientID ;
138
+ List <String > audiences = jwtClaimsSet .getAudience ();
139
+ if (audiences != null && !audiences .isEmpty ()) {
140
+ clientID = new ClientID (audiences .getFirst ());
141
+ } else {
142
+ // Since audience validation failed but we're here, we must have a valid client_id
143
+ String clientIdClaim = (String ) jwtClaimsSet .getClaim ("client_id" );
144
+ clientID = new ClientID (clientIdClaim );
145
+ }
126
146
127
147
JWSAlgorithm jwsAlg = JWSAlgorithm .parse (jwt .getHeader ().getAlgorithm ().getName ());
128
148
return proceedFilter (new FilterInfos (exchange , chain , jwt , jwtClaimsSet , iss , clientID , jwsAlg ));
@@ -142,6 +162,13 @@ private Mono<Void> validateOpaqueReferenceToken(String issBaseUri, String token,
142
162
return gatewayService .getOpaqueTokenIntrospectionUri (issBaseUri )
143
163
.flatMap (uri -> gatewayService .getOpaqueTokenIntrospection (uri , token ))
144
164
.flatMap ((TokenIntrospection tokenIntrospection ) -> {
165
+ // Check client ID against allowedClients
166
+ String clientId = tokenIntrospection .getClientId ();
167
+ if (!isValidClientId (clientId )) {
168
+ LOGGER .info (UNAUTHORIZED_CLIENT_NOT_ALLOWED , clientId );
169
+ return completeWithError (exchange , HttpStatus .UNAUTHORIZED );
170
+ }
171
+
145
172
// TODO really add the client_id header instead of userid
146
173
exchange .getRequest ().mutate ()
147
174
.headers (h -> h .set (HEADER_USER_ID , tokenIntrospection .getClientId ()));
@@ -150,15 +177,84 @@ private Mono<Void> validateOpaqueReferenceToken(String issBaseUri, String token,
150
177
return chain .filter (exchange );
151
178
} else {
152
179
LOGGER .info (UNAUTHORIZED_INVALID_PLAIN_JOSE_OBJECT_ENCODING , exchange .getRequest ().getPath ());
153
- return completeWithCode (exchange , HttpStatus .UNAUTHORIZED );
180
+ return completeWithError (exchange , HttpStatus .UNAUTHORIZED );
154
181
}
155
182
});
156
183
}
157
184
185
+ /**
186
+ * Validates a token's audience or client ID depending on token type.
187
+ *
188
+ * JWT ID tokens (representing end users) typically contain an 'aud' claim that needs validation.
189
+ * JWT access tokens may not have an 'aud' claim but instead use a 'client_id' claim.
190
+ *
191
+ * We first try to validate the audience. Only if no valid audience is found, we fall back to
192
+ * client ID validation. This allows both token types to work with the gateway.
193
+ *
194
+ * IMPORTANT NOTES:
195
+ * - Currently, we only allow GridSuite audience tokens in the allowedAudiences configuration
196
+ * - If we want to allow other frontend applications to access this API:
197
+ * a) This validation logic needs to be reviewed and possibly expanded
198
+ * b) The CORS strategy would need to be modified accordingly to allow those origins
199
+ *
200
+ * @param jwtClaimsSet The JWT claims set
201
+ * @return true if validation passes, false otherwise
202
+ */
203
+ private boolean isValidAudienceOrClientId (JWTClaimsSet jwtClaimsSet ) {
204
+ if (allowedAudiences .isEmpty () && allowedClients .isEmpty ()) {
205
+ LOGGER .debug ("Bypassing audience and client ID validation as both allowed lists are empty" );
206
+ return true ;
207
+ }
208
+
209
+ LOGGER .debug ("checking audience or client ID" );
210
+ List <String > tokenAudiences = jwtClaimsSet .getAudience ();
211
+ if (tokenAudiences != null && !tokenAudiences .isEmpty ()) {
212
+ boolean audienceMatched = tokenAudiences .stream ().anyMatch (aud -> allowedAudiences .contains (aud ));
213
+ if (audienceMatched ) {
214
+ LOGGER .debug ("Audience validation successful" );
215
+ return true ;
216
+ }
217
+ LOGGER .info (UNAUTHORIZED_AUDIENCE_NOT_ALLOWED , tokenAudiences );
218
+ return false ;
219
+ }
220
+ // If there is no audience at all in the token we can try a fallback
221
+ LOGGER .debug ("Audience validation failed for audiences: {}, trying client ID as fallback" , tokenAudiences );
222
+ String clientIdClaim = (String ) jwtClaimsSet .getClaim ("client_id" );
223
+ if (isValidClientId (clientIdClaim )) {
224
+ LOGGER .debug ("Client ID validation successful" );
225
+ return true ;
226
+ }
227
+ LOGGER .info (UNAUTHORIZED_CLIENT_NOT_ALLOWED , jwtClaimsSet .getClaim ("client_id" ));
228
+ return false ;
229
+ }
230
+
231
+ /**
232
+ * Validates whether a client ID is in the list of allowed clients.
233
+ *
234
+ * @param clientId The client ID to validate
235
+ * @return true if validation passes, false otherwise
236
+ */
237
+ private boolean isValidClientId (String clientId ) {
238
+ if (allowedClients .isEmpty ()) {
239
+ return true ;
240
+ }
241
+ return clientId != null && allowedClients .contains (clientId );
242
+ }
243
+
158
244
private Mono <Void > validate (FilterInfos filterInfos , JWKSet jwkset ) throws BadJOSEException , JOSEException {
159
245
160
246
// Create validator for signed ID tokens
161
- // this works with jwt access tokens too (by chance ?) Do we need to modify this ?
247
+ // IMPORTANT: IDTokenValidator strictly enforces OpenID Connect standards including
248
+ // the mandatory presence of the 'aud' (audience) claim. Even though our code has a
249
+ // fallback mechanism in isValidAudienceOrClientId() to validate tokens with only
250
+ // client_id claims, the IDTokenValidator will still throw BadJOSEException with
251
+ // message "Missing JWT audience (aud) claim" for any token without an audience.
252
+ //
253
+ // This creates a behavior inconsistency: tokens with valid client_id but no audience
254
+ // will pass our custom validation (isValidAudienceOrClientId) but will be rejected here.
255
+ //
256
+ // Alternative approaches if client_id-only tokens must be fully supported:
257
+ // Use DefaultJWTClaimsVerifier instead of IDTokenValidator (https://connect2id.com/products/nimbus-jose-jwt/examples/validating-jwt-access-tokens)
162
258
IDTokenValidator validator = new IDTokenValidator (filterInfos .getIss (), filterInfos .getClientID (), filterInfos .getJwsAlg (), jwkset );
163
259
164
260
validator .validate (filterInfos .getJwt (), null );
@@ -183,7 +279,14 @@ private Mono<Void> validate(FilterInfos filterInfos, JWKSet jwkset) throws BadJO
183
279
//we add the subject header
184
280
filterInfos .getExchange ().getRequest ()
185
281
.mutate ()
186
- .headers (h -> h .set (HEADER_USER_ID , filterInfos .getJwtClaimsSet ().getSubject ()));
282
+ .headers (h -> {
283
+ h .set (HEADER_USER_ID , filterInfos .getJwtClaimsSet ().getSubject ());
284
+ // Extract the profile claim if it exists and add it as roles header
285
+ Object profileClaim = filterInfos .getJwtClaimsSet ().getClaim ("profile" );
286
+ if (profileClaim != null ) {
287
+ h .set (HEADER_ROLES , profileClaim .toString ());
288
+ }
289
+ });
187
290
188
291
return filterInfos .getChain ().filter (filterInfos .getExchange ());
189
292
}
@@ -216,7 +319,7 @@ Mono<Void> proceedFilter(FilterInfos filterInfos) {
216
319
jwkSetCache .put (filterInfos .getIss ().getValue (), jwkSet );
217
320
} catch (ParseException e ) {
218
321
LOGGER .info (PARSING_ERROR , filterInfos .getExchange ().getRequest ().getPath ());
219
- return completeWithCode (filterInfos .getExchange (), HttpStatus .INTERNAL_SERVER_ERROR );
322
+ return completeWithError (filterInfos .getExchange (), HttpStatus .INTERNAL_SERVER_ERROR );
220
323
}
221
324
return tryValidate (filterInfos , jwkSet , false );
222
325
})
@@ -234,7 +337,7 @@ private Mono<Void> tryValidate(FilterInfos filterInfos, JWKSet jwksCache, boolea
234
337
return this .proceedFilter (filterInfos );
235
338
} else {
236
339
LOGGER .info (UNAUTHORIZED_THE_TOKEN_CANNOT_BE_TRUSTED , filterInfos .getExchange ().getRequest ().getPath ());
237
- return completeWithCode (filterInfos .getExchange (), HttpStatus .UNAUTHORIZED );
340
+ return completeWithError (filterInfos .getExchange (), HttpStatus .UNAUTHORIZED );
238
341
}
239
342
}
240
343
}
0 commit comments