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 @@ -116,6 +116,34 @@ beans:
`expectedAudience` accepts a comma-separated list (e.g., `"my-client,my-other-client"`) when a token must carry
several audiences at once. Disabled by default for backward compatibility.

=== Token Type and Authorized Party Validation

By default, the security policy does not check the token's `typ` (token type) or `azp` (authorized party) claim.
Two optional settings tighten this:

* `expectedTokenTypes` - a comma-separated allow-list of accepted `typ` values (e.g., `"Bearer"`). When set, a
token whose `typ` is not one of the configured values is rejected. This guards against token-type confusion,
for example an ID token or refresh token being presented where an access token is expected.
* `expectedAuthorizedParty` - the expected `azp` value (e.g., `"my-client"`). When set, a token whose `azp` does
not equal the configured value is rejected, ensuring the token was issued for the expected client.

Both checks apply to local JWT verification and to token introspection, and both are disabled by default for
backward compatibility.

[source,java]
----
KeycloakSecurityPolicy policy = new KeycloakSecurityPolicy(
"http://localhost:8080", "my-realm", "my-client", "client-secret");

// Only accept access tokens (typ=Bearer) issued for "my-client"
policy.setExpectedTokenTypes("Bearer");
policy.setExpectedAuthorizedParty("my-client");

from("direct:protected")
.policy(policy)
.to("mock:result");
----

=== Role-based Authorization

[tabs]
Expand Down
28 changes: 28 additions & 0 deletions components/camel-keycloak/src/main/docs/keycloak-security.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,34 @@ beans:
`expectedAudience` accepts a comma-separated list (e.g., `"my-client,my-other-client"`) when a token must carry
several audiences at once. Disabled by default for backward compatibility.

=== Token Type and Authorized Party Validation

By default, the security policy does not check the token's `typ` (token type) or `azp` (authorized party) claim.
Two optional settings tighten this:

* `expectedTokenTypes` - a comma-separated allow-list of accepted `typ` values (e.g., `"Bearer"`). When set, a
token whose `typ` is not one of the configured values is rejected. This guards against token-type confusion,
for example an ID token or refresh token being presented where an access token is expected.
* `expectedAuthorizedParty` - the expected `azp` value (e.g., `"my-client"`). When set, a token whose `azp` does
not equal the configured value is rejected, ensuring the token was issued for the expected client.

Both checks apply to local JWT verification and to token introspection, and both are disabled by default for
backward compatibility.

[source,java]
----
KeycloakSecurityPolicy policy = new KeycloakSecurityPolicy(
"http://localhost:8080", "my-realm", "my-client", "client-secret");

// Only accept access tokens (typ=Bearer) issued for "my-client"
policy.setExpectedTokenTypes("Bearer");
policy.setExpectedAuthorizedParty("my-client");

from("direct:protected")
.policy(policy)
.to("mock:result");
----

=== Role-based Authorization

[tabs]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,36 @@ public static AccessToken parseAndVerifyAccessToken(String tokenString, PublicKe
public static AccessToken parseAndVerifyAccessToken(
String tokenString, PublicKey publicKey, String expectedIssuer, List<String> expectedAudiences)
throws VerificationException {
return parseAndVerifyAccessToken(tokenString, publicKey, expectedIssuer, expectedAudiences, null, null);
}

/**
* Parses and fully verifies an access token including signature, issuer and, optionally, audience, token type
* ({@code typ}) and authorized party ({@code azp}) validation. This is the recommended method for secure token
* validation.
*
* @param tokenString the JWT token string
* @param publicKey the public key for signature verification
* @param expectedIssuer the expected issuer URL (e.g., "http://localhost:8080/realms/myrealm")
* @param expectedAudiences the expected audiences; when non-empty, the token's "aud" claim must contain
* every one of them (matching Keycloak's own
* {@link TokenVerifier#audience(String...)} check). Pass null or an empty list to
* skip audience validation.
* @param expectedTokenTypes the accepted token types; when non-empty, the token's {@code typ} claim must be
* one of them. Guards against token-type confusion (e.g. an ID or refresh token
* presented where an access token is expected). Pass null or an empty list to skip
* token-type validation.
* @param expectedAuthorizedParty the expected authorized party; when non-empty, the token's {@code azp} claim must
* equal it. Ensures the token was issued for the expected client. Pass null or an
* empty string to skip authorized-party validation.
* @return the verified access token
* @throws VerificationException if verification fails (invalid signature, wrong issuer, expired, missing/wrong
* audience, wrong token type, wrong authorized party, etc.)
*/
public static AccessToken parseAndVerifyAccessToken(
String tokenString, PublicKey publicKey, String expectedIssuer, List<String> expectedAudiences,
List<String> expectedTokenTypes, String expectedAuthorizedParty)
throws VerificationException {
if (publicKey == null) {
throw new VerificationException("Public key is required for secure token verification");
}
Expand Down Expand Up @@ -120,6 +150,31 @@ public static AccessToken parseAndVerifyAccessToken(
String.format("Token issuer mismatch: expected '%s' but got '%s'", expectedIssuer, actualIssuer));
}

// Optional token type (typ) allow-list — guards against token-type confusion, e.g. an ID or refresh token
// being presented where an access token is expected.
if (expectedTokenTypes != null && !expectedTokenTypes.isEmpty()) {
String actualType = token.getType();
if (actualType == null || !expectedTokenTypes.contains(actualType)) {
LOG.error("SECURITY: Token type mismatch - expected one of {} but got '{}'",
expectedTokenTypes, actualType);
throw new VerificationException(
String.format("Token type mismatch: expected one of %s but got '%s'",
expectedTokenTypes, actualType));
}
}

// Optional authorized party (azp) check — ensures the token was issued for the expected client.
if (expectedAuthorizedParty != null && !expectedAuthorizedParty.isEmpty()) {
String actualAzp = token.getIssuedFor();
if (!expectedAuthorizedParty.equals(actualAzp)) {
LOG.error("SECURITY: Token authorized party (azp) mismatch - expected '{}' but got '{}'",
expectedAuthorizedParty, actualAzp);
throw new VerificationException(
String.format("Token authorized party mismatch: expected '%s' but got '%s'",
expectedAuthorizedParty, actualAzp));
}
}

LOG.debug("Token successfully verified for issuer: {}", expectedIssuer);
return token;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,21 @@ public class KeycloakSecurityPolicy implements AuthorizationPolicy {
*/
private String expectedAudience;

/**
* Comma-separated list of accepted token types ({@code typ} claim). When set, a token whose {@code typ} is not one
* of the configured values is rejected. This guards against token-type confusion, e.g. an ID token or refresh token
* being presented where an access token is expected. Disabled by default for backward compatibility. Example:
* "Bearer"
*/
private String expectedTokenTypes;

/**
* Expected authorized party ({@code azp} claim). When set, a token whose {@code azp} does not equal the configured
* value is rejected, ensuring the token was issued for the expected client. Disabled by default for backward
* compatibility. Example: "my-client"
*/
private String expectedAuthorizedParty;

public KeycloakSecurityPolicy() {
this.requiredRoles = "";
this.requiredPermissions = "";
Expand Down Expand Up @@ -470,4 +485,57 @@ public List<String> getExpectedAudienceAsList() {
.filter(s -> !s.isEmpty())
.collect(Collectors.toList());
}

/**
* Gets the accepted token type(s) as a comma-separated string.
*
* @return comma-separated token types (e.g., "Bearer"), or null if not configured
*/
public String getExpectedTokenTypes() {
return expectedTokenTypes;
}

/**
* Sets the accepted token type(s) as a comma-separated string. When set, a token whose "typ" claim is not one of
* the configured values is rejected.
*
* @param expectedTokenTypes comma-separated token types (e.g., "Bearer")
*/
public void setExpectedTokenTypes(String expectedTokenTypes) {
this.expectedTokenTypes = expectedTokenTypes;
}

/**
* Gets the accepted token types as a list.
*
* @return list of accepted token types, or an empty list if not configured
*/
public List<String> getExpectedTokenTypesAsList() {
if (ObjectHelper.isEmpty(expectedTokenTypes)) {
return Collections.emptyList();
}
return Arrays.stream(expectedTokenTypes.split(","))
.map(String::trim)
.filter(s -> !s.isEmpty())
.collect(Collectors.toList());
}

/**
* Gets the expected authorized party ({@code azp}).
*
* @return the expected authorized party, or null if not configured
*/
public String getExpectedAuthorizedParty() {
return expectedAuthorizedParty;
}

/**
* Sets the expected authorized party ({@code azp}). When set, a token whose "azp" claim does not equal the
* configured value is rejected.
*
* @param expectedAuthorizedParty the expected authorized party (e.g., "my-client")
*/
public void setExpectedAuthorizedParty(String expectedAuthorizedParty) {
this.expectedAuthorizedParty = expectedAuthorizedParty;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,14 @@ private void authenticateToken(String accessToken, Exchange exchange) throws Exc
if (!policy.getExpectedAudienceAsList().isEmpty()) {
validateAudienceFromIntrospection(introspectionResult, exchange);
}

if (!policy.getExpectedTokenTypesAsList().isEmpty()) {
validateTokenTypeFromIntrospection(introspectionResult, exchange);
}

if (!ObjectHelper.isEmpty(policy.getExpectedAuthorizedParty())) {
validateAuthorizedPartyFromIntrospection(introspectionResult, exchange);
}
} else {
parseAndVerifyToken(accessToken, exchange);
}
Expand Down Expand Up @@ -284,6 +292,15 @@ private void validateRoles(String accessToken, Exchange exchange) throws Excepti
validateAudienceFromIntrospection(introspectionResult, exchange);
}

// Validate token type / authorized party from introspection result if configured
if (!policy.getExpectedTokenTypesAsList().isEmpty()) {
validateTokenTypeFromIntrospection(introspectionResult, exchange);
}

if (!ObjectHelper.isEmpty(policy.getExpectedAuthorizedParty())) {
validateAuthorizedPartyFromIntrospection(introspectionResult, exchange);
}

userRoles = KeycloakSecurityHelper.extractRolesFromIntrospection(
introspectionResult, policy.getRealm(), policy.getClientId());
} else {
Expand Down Expand Up @@ -340,7 +357,8 @@ private AccessToken parseAndVerifyToken(String accessToken, Exchange exchange) t
if (publicKey != null) {
try {
return KeycloakSecurityHelper.parseAndVerifyAccessToken(
accessToken, publicKey, expectedIssuer, policy.getExpectedAudienceAsList());
accessToken, publicKey, expectedIssuer, policy.getExpectedAudienceAsList(),
policy.getExpectedTokenTypesAsList(), policy.getExpectedAuthorizedParty());
} catch (VerificationException e) {
LOG.error("Token verification failed: {}", e.getMessage());
throw new CamelAuthorizationException("Token verification failed: " + e.getMessage(), exchange, e);
Expand Down Expand Up @@ -416,6 +434,57 @@ private void validateAudienceFromIntrospection(
LOG.debug("Audience validation from introspection successful: {}", expectedAudiences);
}

/**
* Validates the token type ({@code typ}) from an introspection result. When token-type validation is configured, a
* token whose {@code typ} is missing or not among the accepted values is rejected — guarding against token-type
* confusion (e.g. an ID or refresh token presented where an access token is expected).
*/
private void validateTokenTypeFromIntrospection(
KeycloakTokenIntrospector.IntrospectionResult introspectionResult, Exchange exchange)
throws CamelAuthorizationException {
List<String> expectedTokenTypes = policy.getExpectedTokenTypesAsList();
// Use the JWT "typ" claim (token category, e.g. Bearer/Refresh/ID), which Keycloak forwards on its
// introspection response — not the RFC 7662 "token_type" field, which is the OAuth token type ("Bearer")
// and does not distinguish access from refresh/ID tokens, i.e. it cannot express what this check validates.
Object typeClaim = introspectionResult.getClaim("typ");
String actualType = typeClaim instanceof String s ? s : null;

if (actualType == null || !expectedTokenTypes.contains(actualType)) {
LOG.error("SECURITY: Token type mismatch from introspection - expected one of {} but got '{}'",
expectedTokenTypes, actualType);
throw new CamelAuthorizationException(
String.format("Token type mismatch: expected one of %s but got '%s'",
expectedTokenTypes, actualType),
exchange);
}

LOG.debug("Token type validation from introspection successful: {}", expectedTokenTypes);
}

/**
* Validates the authorized party ({@code azp}) from an introspection result. When authorized-party validation is
* configured, a token whose {@code azp} does not equal the expected value is rejected — ensuring the token was
* issued for the expected client.
*/
private void validateAuthorizedPartyFromIntrospection(
KeycloakTokenIntrospector.IntrospectionResult introspectionResult, Exchange exchange)
throws CamelAuthorizationException {
String expectedAuthorizedParty = policy.getExpectedAuthorizedParty();
Object azpClaim = introspectionResult.getClaim("azp");
String actualAzp = azpClaim instanceof String s ? s : null;

if (!expectedAuthorizedParty.equals(actualAzp)) {
LOG.error("SECURITY: Token authorized party (azp) mismatch from introspection - expected '{}' but got '{}'",
expectedAuthorizedParty, actualAzp);
throw new CamelAuthorizationException(
String.format("Token authorized party mismatch: expected '%s' but got '%s'",
expectedAuthorizedParty, actualAzp),
exchange);
}

LOG.debug("Authorized party validation from introspection successful: {}", expectedAuthorizedParty);
}

private void validatePermissions(String accessToken, Exchange exchange) throws Exception {
try {
Set<String> userPermissions;
Expand All @@ -440,6 +509,16 @@ private void validatePermissions(String accessToken, Exchange exchange) throws E
validateAudienceFromIntrospection(introspectionResult, exchange);
}

// Validate token type and authorized party too, otherwise these checks could be bypassed by
// configuring only permissions (which skips authenticateToken/validateRoles) with introspection.
if (!policy.getExpectedTokenTypesAsList().isEmpty()) {
validateTokenTypeFromIntrospection(introspectionResult, exchange);
}

if (!ObjectHelper.isEmpty(policy.getExpectedAuthorizedParty())) {
validateAuthorizedPartyFromIntrospection(introspectionResult, exchange);
}

userPermissions = KeycloakSecurityHelper.extractPermissionsFromIntrospection(introspectionResult);
} else {
// Use local JWT parsing with secure verification
Expand Down
Loading