JAX-RS: Token Authorization

Introduction

CXF JAX-RS offers an extension that lets users enforce fine-grained Claims Based Access Control (CBAC) using the Claim and Claims annotations and the ClaimMode enum. It works with both SAML and JWT tokens.

See also JAX-RS XML Security, JAX-RS SAML and JAX-RS JOSE.

Maven dependencies

<dependency>
  <groupId>org.apache.cxf</groupId>
  <artifactId>cxf-rt-security</artifactId>
  <version>${cxf.version}</version>
</dependency>

In addition, cxf-rt-rs-security-xml is required if you are working with SAML tokens, and cxf-rt-rs-security-jose-jaxrs is required if you are working with JWT tokens. ClaimsAuthorizingFilter and SimpleAuthorizingFilter are in cxf-rt-frontend-jaxrs.

Claims based access control

Claims annotations

The Claim, Claims and ClaimMode types live in the org.apache.cxf.security.claims.authorization package. Here is a simple code fragment that secures a service object using Claims annotations:

import org.apache.cxf.security.claims.authorization.Claim;
import org.apache.cxf.security.claims.authorization.Claims;

@Path("/bookstore")
public class SecureClaimBookStore {

    @POST
    @Path("/books")
    @Produces("application/xml")
    @Consumes("application/xml")
    @Claims({
        @Claim({"admin" }),
        @Claim(name = "http://claims/authentication-format",
               format = "http://claims/authentication",
               value = {"fingertip", "smartcard" })
    })
    public Book addBook(Book book) {
        return book;
    }
}

SecureClaimBookStore.addBook(Book) can only be invoked if the Subject meets the following requirements: it must have a Claim with the value "admin", and another Claim confirming that it was authenticated using either the 'fingertip' or the 'smartcard' method.

Note that @Claim({"admin"}) has no name or format set, so it relies on the default values:

  • name: http://schemas.xmlsoap.org/ws/2005/05/identity/claims/role
  • format: urn:oasis:names:tc:SAML:2.0:attrname-format:unspecified

You can always set name and format explicitly to scope a given claim value.

SAML vs JWT matching. For SAML tokens, a Claim matches a SAML attribute only if both the name and the NameFormat are equal. For JWT tokens, the format is ignored and the Claim name is compared with the JWT claim name as it appears in the token. A JWT carrying "role": "admin" therefore does not match @Claim({"admin"}), because the default name is the SAML role URI. Use @Claim(name = "role", value = {"admin"}), or map the name with nameAliases (see below).

In the example above, the Claim named "http://claims/authentication-format" has two values, 'fingertip' and 'smartcard'. By default, to meet this Claim the Subject needs a Claim with either value. If the Subject must have both values, set matchAll = true:

import org.apache.cxf.security.claims.authorization.Claim;
import org.apache.cxf.security.claims.authorization.Claims;

@Path("/bookstore")
public class SecureClaimBookStore {

    @POST
    @Path("/books")
    @Produces("application/xml")
    @Consumes("application/xml")
    @Claims({
        @Claim({"admin" }),
        @Claim(name = "http://claims/authentication-format",
               format = "http://claims/authentication",
               value = {"fingertip", "smartcard" },
               matchAll = true)
    })
    public Book addBook(Book book) {
        return book;
    }
}

Claims can be specified using an individual @Claim annotation. They can be set at class level and overridden at method level, and a lax mode of check can be specified:

import org.apache.cxf.security.claims.authorization.Claim;
import org.apache.cxf.security.claims.authorization.ClaimMode;
import org.apache.cxf.security.claims.authorization.Claims;

@Path("/bookstore")
@Claim({"user"})
public class SecureClaimBookStore {

    @POST
    @Path("/books")
    @Produces("application/xml")
    @Consumes("application/xml")
    @Claims({
        @Claim({"admin" }),
        @Claim(name = "http://claims/authentication-format",
               format = "http://claims/authentication",
               value = {"fingertip", "smartcard" },
               matchAll = true)
    })
    public Book addBook(Book book) {
        return book;
    }

    @GET
    @Claim(name = "http://claims/authentication-format",
           format = "http://claims/authentication",
           value = {"password" },
           mode = ClaimMode.LAX)
    public Book getBook() {
        //...
    }

    @GET
    public BookList getBookList() {
        //...
    }
}

In the above example:

  • getBookList() can be invoked if the Subject has a Claim with the value "user".
  • addBook() overrides the class-level Claim: "admin" is expected instead of "user", together with the authentication format Claim. A method-level Claim replaces a class-level Claim only when both have the same name and format; class-level Claims with a different name or format still apply.
  • getBook() can be invoked if the Subject has a Claim with the value "user". In addition, if it has an authentication format Claim, that Claim must have the value "password". With ClaimMode.LAX, a missing Claim is accepted; only a Claim that is present with non-matching values fails.

Methods that have no Claims annotations, either on the method or on the class, are not restricted by ClaimsAuthorizingInterceptor.

org.apache.cxf.rt.security.claims.interceptor.ClaimsAuthorizingInterceptor enforces the CBAC rules. It can also be configured with the rules directly, using its claims property, which is useful if no Claim annotations are expected in the code. The nameAliases and formatAliases map properties are supported to make @Claim annotations look a bit simpler, for example:

@Claim(name = "auth-format", format = "authentication", value = {"password" })

where "auth-format" and "authentication" are aliases for "http://claims/authentication-format" and "http://claims/authentication" respectively.

Aliases are resolved when securedObject is set, so set nameAliases and formatAliases on the interceptor before securedObject. Otherwise the aliases are not applied, and rules using them will not match as intended.

Enforcing Claims authorization

Adding Claims annotations as in the examples above is not enough on its own to enforce claims based authorization.

First, configure the interceptors/filters that authenticate the type of token you want to extract claims from. See the JAX-RS SAML page for configuring SAML, and the JAX-RS JOSE page for configuring JWT.

For both SAML and JWT, once the incoming token is validated, a ClaimsSecurityContext is created. It contains the claims from the token, the authenticated subject, and the roles. For SAML, a security context is only created if the assertion is signed, unless security.enable.unsigned-saml-assertion.principal is set to true. If there is no ClaimsSecurityContext, ClaimsAuthorizingInterceptor rejects the request.

To enforce claims authorization, create a ClaimsAuthorizingInterceptor and pass it a reference to the secured object. Then either add it as an "inInterceptor", or wrap it in the JAX-RS filter org.apache.cxf.jaxrs.security.ClaimsAuthorizingFilter by passing it as a constructor argument. A failed check through the filter returns HTTP 403.

Here is an example of enforcing Claims authorization against a JWT token. BookStoreAuthn is the service object annotated with Claims annotations. The ClaimsAuthorizingFilter wraps a ClaimsAuthorizingInterceptor configured with the service bean, and is added as a JAX-RS provider. A JwtAuthenticationFilter validates the received JWT token and sets up the ClaimsSecurityContext. The rs.security.signature.in.properties property is used to verify the signature on the received token.

<bean id="serviceBean" class="org.apache.cxf.systest.jaxrs.security.jose.jwt.BookStoreAuthn"/>

<bean id="claimsInterceptor"
      class="org.apache.cxf.rt.security.claims.interceptor.ClaimsAuthorizingInterceptor">
    <property name="securedObject" ref="serviceBean"/>
</bean>

<bean id="claimsHandler" class="org.apache.cxf.jaxrs.security.ClaimsAuthorizingFilter">
    <constructor-arg ref="claimsInterceptor"/>
</bean>

<bean id="jwtAuthzFilter" class="org.apache.cxf.rs.security.jose.jaxrs.JwtAuthenticationFilter">
    <property name="roleClaim" value="role"/>
</bean>

<jaxrs:server address="https://localhost:${testutil.ports.jaxrs-jwt-authn-authz}/signedjwtauthz">
    <jaxrs:serviceBeans>
        <ref bean="serviceBean"/>
    </jaxrs:serviceBeans>
    <jaxrs:providers>
        <ref bean="jwtAuthzFilter"/>
        <ref bean="claimsHandler"/>
    </jaxrs:providers>
    <jaxrs:properties>
        <entry key="rs.security.signature.in.properties"
               value="org/apache/cxf/systest/jaxrs/security/bob.jwk.properties"/>
    </jaxrs:properties>
</jaxrs:server>

Role based access control

If a SAML Assertion or JWT token has claims that are known to represent roles, those claims can easily be used with an RBAC system. For JWT, the roleClaim property of JwtAuthenticationFilter names the claim holding the roles. Its value can be a JSON array of strings, or a single comma-separated string such as "admin,user". Don't put spaces after the commas, because the individual values are not trimmed. For SAML, see the role properties described below.

SimpleAuthorizingInterceptor

One way to make sure that only users in a given role can access a method of the service bean is to use CXF's SimpleAuthorizingInterceptor. Its methodRolesMap property maps methods to space-separated lists of roles. A key can be either:

  • a method name, such as echoBook, which applies to all overloads of that method; or
  • a full method signature in the form returnType methodName(paramType1, paramType2), using Class.getName() type names, such as void transfer(java.lang.String, long).

The globalRoles property sets the roles required for methods that have no entry in methodRolesMap.

Add the interceptor to the inInterceptor chain of the service endpoint. For example:

<bean id="serviceBean" class="org.apache.cxf.systest.jaxrs.security.jose.jwt.BookStoreAuthn"/>

<bean id="jwtAuthzFilter" class="org.apache.cxf.rs.security.jose.jaxrs.JwtAuthenticationFilter">
    <property name="roleClaim" value="role"/>
</bean>

<bean id="authorizationInterceptor"
      class="org.apache.cxf.interceptor.security.SimpleAuthorizingInterceptor">
    <property name="methodRolesMap">
        <map>
            <entry key="echoBook" value="boss"/>
            <entry key="echoBook2" value="boss"/>
        </map>
    </property>
</bean>

<jaxrs:server address="https://localhost:${testutil.ports.jaxrs-jwt-authn-authz}/signedjwtauthz">
    <jaxrs:serviceBeans>
        <ref bean="serviceBean"/>
    </jaxrs:serviceBeans>
    <jaxrs:providers>
        <ref bean="jwtAuthzFilter"/>
    </jaxrs:providers>
    <jaxrs:inInterceptors>
        <ref bean="authorizationInterceptor"/>
    </jaxrs:inInterceptors>
    <jaxrs:properties>
        <entry key="rs.security.signature.in.properties"
               value="org/apache/cxf/systest/jaxrs/security/bob.jwk.properties"/>
    </jaxrs:properties>
</jaxrs:server>

Unlisted methods are not protected. A method with no roles configured, either in methodRolesMap or through globalRoles, is allowed for any authenticated user. It is also allowed for unauthenticated users, because allowAnonymousUsers defaults to true. To protect every method, set globalRoles, and consider setting allowAnonymousUsers to false.

Using annotations

Instead of mapping methods to roles in SimpleAuthorizingInterceptor, you can annotate the methods of the service bean with jakarta.annotation.security.RolesAllowed (the default) or another role annotation, such as Spring Security's org.springframework.security.access.annotation.Secured. For example:

import org.springframework.security.access.annotation.Secured;

@Path("/bookstore")
public class SecureBookStore {

    @POST
    @Secured("admin")
    public Book addBook(Book book) {
        return book;
    }
}

Here @Secured can be replaced with @RolesAllowed if needed. SecureAnnotationsInterceptor reads the annotations from the secured object; class-level annotations apply to methods that are not annotated themselves. The following configuration enables it:

<bean id="serviceBeanRoles" class="org.apache.cxf.systest.jaxrs.security.saml.SecureBookStore"/>

<bean id="samlEnvHandler" class="org.apache.cxf.rs.security.saml.SamlEnvelopedInHandler">
    <property name="securityContextProvider">
        <bean class="org.apache.cxf.systest.jaxrs.security.saml.CustomSecurityContextProvider"/>
    </property>
</bean>

<bean id="authorizationInterceptor" class="org.apache.cxf.interceptor.security.SecureAnnotationsInterceptor">
    <property name="securedObject" ref="serviceBeanRoles"/>
    <!-- Only needed when not using the default jakarta.annotation.security.RolesAllowed -->
    <property name="annotationClassName"
              value="org.springframework.security.access.annotation.Secured"/>
</bean>

<bean id="rolesHandler" class="org.apache.cxf.jaxrs.security.SimpleAuthorizingFilter">
    <property name="interceptor" ref="authorizationInterceptor"/>
</bean>

<jaxrs:server address="/saml-roles">
    <jaxrs:serviceBeans>
        <ref bean="serviceBeanRoles"/>
    </jaxrs:serviceBeans>
    <jaxrs:providers>
        <ref bean="samlEnvHandler"/>
        <ref bean="rolesHandler"/>
    </jaxrs:providers>

    <!-- If the default role name and format are not used:

    <jaxrs:properties>
        <entry key="org.apache.cxf.saml.claims.role.nameformat"
               value="urn:oasis:names:tc:SAML:2.0:attrname-format:uri"/>
        <entry key="org.apache.cxf.saml.claims.role.qualifier"
               value="urn:oid:1.3.6.1.4.1.5923.1.1.1.1"/>
    </jaxrs:properties>
    -->
</jaxrs:server>

That is all that is needed. To help the default SAML SecurityContextProvider work out which attributes are roles, you can set the two properties shown above. This is not needed if the role attributes use the default values:

  • Name: http://schemas.xmlsoap.org/ws/2005/05/identity/claims/role
  • NameFormat: urn:oasis:names:tc:SAML:2.0:attrname-format:unspecified

The role name can also be set with the security.saml-role-attributename property.

You can combine RBAC and CBAC to enforce more sophisticated access control rules, while keeping existing code that relies on @RolesAllowed or @Secured intact. Configure a ClaimsAuthorizingInterceptor with the Claims rules directly, wrap it in a ClaimsAuthorizingFilter, and register it alongside SimpleAuthorizingFilter.

SecureAnnotationsInterceptor can handle different types of role annotations through its annotationClassName property, with @RolesAllowed supported by default.