Limit access Token expiration for jwt authorization grant (#44775)

Closes #43972


Signed-off-by: Giuseppe Graziano <g.graziano94@gmail.com>
This commit is contained in:
Giuseppe Graziano 2025-12-18 09:21:29 +01:00 committed by GitHub
parent f5a3086027
commit 790fb557db
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 70 additions and 6 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

After

Width:  |  Height:  |  Size: 140 KiB

View File

@ -58,6 +58,7 @@ The Identity Provider (both types commented in the introduction) needs to also b
* **Max allowed assertion expiration**: The maximum expiration the server will allow in the assertion. Default 5 minutes.
* **Assertion signature algorithm**: The signature algorithm that is valid for assertions. If not specified any signature is valid.
* **Allowed clock skew**: Clock skew in seconds that is tolerated when validating identity provider tokens. Default value is zero.
* **Limit access token expiration**: If enabled the access token lifespan will be limited to the expiration of the JWT assertion but only if the JWT assertion expiration is less than the calculated access token expiration.
.OIDC Identity Provider configuration for JWT Authorization Grant
image::jwt-authorization-grant-oidc-provider-config.png[Identity Provider configuration for JWT Authorization Grant]

View File

@ -906,6 +906,8 @@ jwtAuthorizationGrantMaxAllowedAssertionExpiration=Max allowed assertion expirat
jwtAuthorizationGrantMaxAllowedAssertionExpirationHelp=Insert the max allowed expiration that the assertion can have.
jwtAuthorizationGrantAssertionSignatureAlg=Assertion signature algorithm
jwtAuthorizationGrantAssertionSignatureAlgHelp=Signature algorithm that should be used to sign the assertion, if not specified any signature algorithm will be valid.
jwtAuthorizationGrantLimitAccessTokenExp=Limit access token expiration
jwtAuthorizationGrantLimitAccessTokenExpHelp=If enabled the access token lifespan will be limited to the expiration of the JWT assertion but only if the JWT assertion expiration is less than the calculated access token expiration.
addJWTAuthorizationGrantProvider=Add JWT Authorization Grant Provider
jwtAuthorizationGrantJWKSUrl=JWKS URL
jwtAuthorizationGrantJWKSUrlHelp=URL where identity provider keys in JWK format are stored. See the JWK specification for more details

View File

@ -58,6 +58,12 @@ export const JWTAuthorizationGrantAssertionSettings = () => {
defaultValue: "",
}}
/>
<DefaultSwitchControl
name="config.jwtAuthorizationGrantLimitAccessTokenExp"
label={t("jwtAuthorizationGrantLimitAccessTokenExp")}
labelIcon={t("jwtAuthorizationGrantLimitAccessTokenExpHelp")}
stringify
/>
</>
);
};

View File

@ -36,4 +36,6 @@ public interface JWTAuthorizationGrantProvider <C extends IdentityProviderModel>
int getMaxAllowedExpiration();
String getAssertionSignatureAlg();
boolean isLimitAccessTokenExpiration();
}

View File

@ -17,6 +17,8 @@ public interface JWTAuthorizationGrantConfig {
String JWT_AUTHORIZATION_GRANT_ASSERTION_SIGNATURE_ALG = "jwtAuthorizationGrantAssertionSignatureAlg";
String JWT_AUTHORIZATION_GRANT_LIMIT_ACCESS_TOKEN_EXP = "jwtAuthorizationGrantLimitAccessTokenExp";
String JWT_AUTHORIZATION_GRANT_ALLOWED_CLOCK_SKEW = "jwtAuthorizationGrantAllowedClockSkew";
String PUBLIC_KEY_SIGNATURE_VERIFIER = "publicKeySignatureVerifier";
@ -45,6 +47,10 @@ public interface JWTAuthorizationGrantConfig {
return getConfig().get(JWT_AUTHORIZATION_GRANT_ASSERTION_SIGNATURE_ALG);
}
default boolean isJwtAuthorizationGrantLimitAccessTokenExp() {
return Boolean.parseBoolean(getConfig().getOrDefault(JWT_AUTHORIZATION_GRANT_LIMIT_ACCESS_TOKEN_EXP, "false"));
}
default int getJWTAuthorizationGrantAllowedClockSkew() {
String allowedClockSkew = getConfig().get(JWT_AUTHORIZATION_GRANT_ALLOWED_CLOCK_SKEW);
if (allowedClockSkew == null || allowedClockSkew.isEmpty()) {

View File

@ -74,6 +74,11 @@ public class JWTAuthorizationGrantIdentityProvider implements JWTAuthorizationGr
return StringUtil.isBlank(alg) ? null : alg;
}
@Override
public boolean isLimitAccessTokenExpiration() {
return getConfig().isJwtAuthorizationGrantLimitAccessTokenExp();
}
@Override
public JWTAuthorizationGrantIdentityProviderConfig getConfig() {
return this.config instanceof JWTAuthorizationGrantIdentityProviderConfig ? (JWTAuthorizationGrantIdentityProviderConfig)this.config : null;

View File

@ -1116,4 +1116,9 @@ public class OIDCIdentityProvider extends AbstractOAuth2IdentityProvider<OIDCIde
public String getAssertionSignatureAlg() {
return getConfig().getJWTAuthorizationGrantAssertionSignatureAlg();
}
@Override
public boolean isLimitAccessTokenExpiration() {
return getConfig().isJwtAuthorizationGrantLimitAccessTokenExp();
}
}

View File

@ -129,7 +129,13 @@ public class JWTAuthorizationGrantType extends OAuth2GrantTypeBase {
event.session(userSession);
ClientSessionContext clientSessionCtx = TokenManager.attachAuthenticationSession(this.session, userSession,
authSession, authorizationGrantContext.getRestrictedScopes(), false);
return createTokenResponse(user, userSession, clientSessionCtx, scopeParam, true, null);
TokenManager.AccessTokenResponseBuilder responseBuilder = createTokenResponseBuilder(user, userSession, clientSessionCtx, scopeParam, null);
if (jwtAuthorizationGrantProvider.isLimitAccessTokenExpiration()) {
if (authorizationGrantContext.getJWT().getExp() < responseBuilder.getAccessToken().getExp()) {
responseBuilder.getAccessToken().exp(authorizationGrantContext.getJWT().getExp());
}
}
return createTokenResponse(responseBuilder, clientSessionCtx, true);
} catch (CorsErrorResponseException e) {
throw e;
} catch (Exception e) {

View File

@ -111,13 +111,12 @@ public abstract class OAuth2GrantTypeBase implements OAuth2GrantType {
this.tokenManager = (TokenManager) context.tokenManager;
}
protected Response createTokenResponse(UserModel user, UserSessionModel userSession, ClientSessionContext clientSessionCtx,
String scopeParam, boolean code, Function<TokenManager.AccessTokenResponseBuilder, ClientPolicyContext> clientPolicyContextGenerator) {
protected TokenManager.AccessTokenResponseBuilder createTokenResponseBuilder(UserModel user, UserSessionModel userSession, ClientSessionContext clientSessionCtx, String scopeParam, Function<TokenManager.AccessTokenResponseBuilder, ClientPolicyContext> clientPolicyContextGenerator) {
clientSessionCtx.setAttribute(Constants.GRANT_TYPE, context.getGrantType());
AccessToken token = tokenManager.createClientAccessToken(session, realm, client, user, userSession, clientSessionCtx);
TokenManager.AccessTokenResponseBuilder responseBuilder = tokenManager
.responseBuilder(realm, client, event, session, userSession, clientSessionCtx).accessToken(token);
.responseBuilder(realm, client, event, session, userSession, clientSessionCtx).accessToken(token);
boolean useRefreshToken = useRefreshToken();
if (useRefreshToken) {
responseBuilder.generateRefreshToken();
@ -153,6 +152,10 @@ public abstract class OAuth2GrantTypeBase implements OAuth2GrantType {
}
}
return responseBuilder;
}
protected Response createTokenResponse(TokenManager.AccessTokenResponseBuilder responseBuilder, ClientSessionContext clientSessionCtx, boolean code) {
AccessTokenResponse res = null;
if (code) {
try {
@ -160,7 +163,7 @@ public abstract class OAuth2GrantTypeBase implements OAuth2GrantType {
} catch (RuntimeException re) {
if ("can not get encryption KEK".equals(re.getMessage())) {
throw new CorsErrorResponseException(cors, OAuthErrorException.INVALID_REQUEST,
"can not get encryption KEK", Response.Status.BAD_REQUEST);
"can not get encryption KEK", Response.Status.BAD_REQUEST);
} else {
throw re;
}
@ -177,6 +180,13 @@ public abstract class OAuth2GrantTypeBase implements OAuth2GrantType {
return cors.add(Response.ok(res).type(MediaType.APPLICATION_JSON_TYPE));
}
protected Response createTokenResponse(UserModel user, UserSessionModel userSession, ClientSessionContext clientSessionCtx,
String scopeParam, boolean code, Function<TokenManager.AccessTokenResponseBuilder, ClientPolicyContext> clientPolicyContextGenerator) {
TokenManager.AccessTokenResponseBuilder responseBuilder = createTokenResponseBuilder(user, userSession,
clientSessionCtx, scopeParam, clientPolicyContextGenerator);
return createTokenResponse(responseBuilder, clientSessionCtx, code);
}
protected void checkAndBindMtlsHoKToken(TokenManager.AccessTokenResponseBuilder responseBuilder, boolean useRefreshToken) {
// KEYCLOAK-6771 Certificate Bound Token
// https://tools.ietf.org/html/draft-ietf-oauth-mtls-08#section-3

View File

@ -78,6 +78,7 @@ public abstract class AbstractJWTAuthorizationGrantTest extends BaseAbstractJWTA
//reduce max expiration to 10 seconds
realm.updateIdentityProviderWithCleanup(IDP_ALIAS, rep -> {
rep.getConfig().put(OIDCIdentityProviderConfig.JWT_AUTHORIZATION_GRANT_MAX_ALLOWED_ASSERTION_EXPIRATION, "10");
rep.getConfig().put(OIDCIdentityProviderConfig.JWT_AUTHORIZATION_GRANT_LIMIT_ACCESS_TOKEN_EXP, "false");
});
jwt = getIdentityProvider().encodeToken(createAuthorizationGrantToken("basic-user-id", oAuthClient.getEndpoints().getIssuer(), IDP_ISSUER, Time.currentTime() + 11L));
@ -275,6 +276,22 @@ public abstract class AbstractJWTAuthorizationGrantTest extends BaseAbstractJWTA
}
}
@Test
public void textLimitAccessTokenExpiration() {
realm.updateIdentityProviderWithCleanup(IDP_ALIAS, rep -> {
rep.getConfig().put(OIDCIdentityProviderConfig.JWT_AUTHORIZATION_GRANT_LIMIT_ACCESS_TOKEN_EXP, "true");
});
int accessCodeLifeSpan = realm.admin().toRepresentation().getAccessTokenLifespan();
String jwt = getIdentityProvider().encodeToken(createAuthorizationGrantToken(accessCodeLifeSpan));
AccessTokenResponse response = oAuthClient.jwtAuthorizationGrantRequest(jwt).send();
MatcherAssert.assertThat(response.getExpiresIn(), Matchers.allOf(Matchers.lessThanOrEqualTo(accessCodeLifeSpan), Matchers.greaterThan(accessCodeLifeSpan - 5)));
jwt = getIdentityProvider().encodeToken(createAuthorizationGrantToken(120L));
response = oAuthClient.jwtAuthorizationGrantRequest(jwt).send();
MatcherAssert.assertThat(response.getExpiresIn(), Matchers.allOf(Matchers.lessThanOrEqualTo(120), Matchers.greaterThan(115)));
}
@Test
public void testSuccessGrant() {
String jwt = getIdentityProvider().encodeToken(createDefaultAuthorizationGrantToken());

View File

@ -87,7 +87,11 @@ public class BaseAbstractJWTAuthorizationGrantTest {
TimeOffSet timeOffSet;
protected JsonWebToken createDefaultAuthorizationGrantToken() {
return createAuthorizationGrantToken("basic-user-id", oAuthClient.getEndpoints().getIssuer(), IDP_ISSUER, Time.currentTime() + 300L, null, null);
return createAuthorizationGrantToken("basic-user-id", oAuthClient.getEndpoints().getIssuer(), IDP_ISSUER, Time.currentTime() + 300L);
}
protected JsonWebToken createAuthorizationGrantToken(long expiration) {
return createAuthorizationGrantToken("basic-user-id", oAuthClient.getEndpoints().getIssuer(), IDP_ISSUER, Time.currentTime() + expiration);
}
protected JsonWebToken createAuthorizationGrantToken(String subject, String audience, String issuer) {