Skip to content

clerk-endpoint-security

Using Clerk and Spring Security for API Authentication

Clerk is a user management SaaS service that allows independent developers to quickly integrate it, saving a lot of time and costs. Clerk provides basic user login authentication and organization role-based permissions. By leveraging these features, you can integrate API authorization and authentication.

Implemented Features

  • Session authentication based on user tokens
  • User API resource authorization
    • Based on request URL
    • Based on method security annotations

Overall Approach

clerk-endpoint-security

  • Each request carries the Clerk login token, transmitted through the request header as a Bearer token.

  • Based on the security chain of responsibility logic in Spring Security, requests are processed through the FilterChain for authentication and authorization.

    • AuthenticationFilter is used to verify if the Clerk token represents a valid session. Upon successful authentication, it retrieves all organization role permissions related to the user, constructs the domain model Authentication, and stores the valid user credentials.
    • AuthorizationFilter is used to verify if the token's user's organization role permissions match the requested permissions.
    • SecurityContext stores the current context's user Authentication credentials.
  • Based on the Spring Method Security interceptor logic, the user Authentication credentials stored in SecurityContext are used for authorization checks on methods annotated with security annotations (@PreAuthorize, @PostAuthorize, etc.).

A Convention

How are request URLs mapped to Clerk's organization role permissions?

  • The concepts of user, organization, role, and permission in Clerk: A user can belong to multiple organizations, each organization can have multiple roles, and roles can have multiple permissions.

  • The convention is to map request URLs to permission codes consistently, for example:

    • /ai/chat <--> org:ai:chat
    • /default/chat <--> org:default:chat
    • /chat <--> org:default:chat
    • /ai/chat/gpt <--> org:ai:chat_gpt

    clerk-endpoint-security

Specific Implementation

How to verify the validity of the Clerk token?

  • The Java backend API provided by Clerk is used to verify the validity of user tokens and retrieve the user's organization role permissions.
java
package com.juliusyolo.service.impl;

import com.clerk.backend_api.Clerk;
import com.clerk.backend_api.models.components.Client;
import com.clerk.backend_api.models.components.Session;
import com.clerk.backend_api.models.components.Status;
import com.clerk.backend_api.models.operations.GetUserResponse;
import com.clerk.backend_api.models.operations.UsersGetOrganizationMembershipsResponse;
import com.clerk.backend_api.models.operations.VerifyClientRequestBody;
import com.clerk.backend_api.models.operations.VerifyClientResponse;
import com.juliusyolo.component.ClerkProperties;
import com.juliusyolo.component.EndpointProperties;
import com.juliusyolo.exception.UserAuthenticationException;
import com.juliusyolo.model.UserModel;
import com.juliusyolo.model.UserPermissionAuthenticationToken;
import com.juliusyolo.service.ClerkService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.util.CollectionUtils;

import java.util.Arrays;
import java.util.List;
import java.util.Objects;
import java.util.stream.Collectors;

/**
 * <p>
 * a clerk implement
 * </p>
 *
 * @author julius.yolo
 * @version : ClerkService v0.1
 */
public class ClerkServiceImpl implements ClerkService {

    private final static Logger LOGGER =
            LoggerFactory.getLogger(ClerkServiceImpl.class);

    private final Clerk clerk;

    private final ClerkProperties clerkProperties;

    private final EndpointProperties endpointProperties;

    public ClerkServiceImpl(Clerk clerk, ClerkProperties clerkProperties, EndpointProperties endpointProperties) {
        this.clerk = clerk;
        this.clerkProperties = clerkProperties;
        this.endpointProperties = endpointProperties;
    }

    @Override
    public String getActiveClerkUserId(String token) {
        LOGGER.info("ClerkServiceImpl#getActiveClerkUserId token:{}", token);
        try {
            VerifyClientRequestBody req = VerifyClientRequestBody.builder().token(token).build();
            VerifyClientResponse res = clerk.clients().verify().request(req).call();
            if (res.client().isPresent()) {
                Client client = res.client().get();
                if (CollectionUtils.isEmpty(client.sessions())) {
                    return null;
                }
                List<Session> sessions = client.sessions().stream().filter(session -> Objects.equals(session.status(), Status.ACTIVE)).toList();
                if (CollectionUtils.isEmpty(sessions)) {
                    return null;
                }
                return sessions.getFirst().userId();
            }
            return null;
        } catch (Exception e) {
            throw new UserAuthenticationException("No client found", e);
        }
    }

    @Override
    public UserModel verifyToken(UserPermissionAuthenticationToken token) {
        String userId = this.getActiveClerkUserId(token.getToken());
        if (userId == null) {
            throw new UserAuthenticationException("No active clerk user found");
        }
        try {
            UserModel userModel = null;
            GetUserResponse userRes = clerk.users().get()
                    .userId(userId)
                    .call();

            if (userRes.user().isPresent()) {
                userModel = new UserModel();
                userModel.setUser(userRes.user().get());
                UsersGetOrganizationMembershipsResponse membershipsRes = clerk.users().getOrganizationMemberships()
                        .userId(userId)
                        .limit(clerkProperties.maxOrganizationCount())
                        .offset(0L)
                        .call();

                userModel.setOrganizationMemberships(membershipsRes.organizationMemberships());
            }
            return userModel;
        } catch (Exception e) {
            throw new UserAuthenticationException("Fetch user failed", e);
        }

    }

    @Override
    public boolean verifyAuthorization(UserPermissionAuthenticationToken token, String path) {
        if (!endpointProperties.pathAuthorizationEnable()) {
            return true;
        }
        if (path.startsWith(endpointProperties.prefix())) {
            path = path.substring(endpointProperties.prefix().length());
        }
        if (path.startsWith("/")) {
            path = path.substring(1);
        }
        String[] split = path.split("/");
        if (split.length >= 2) {
            path = "org:" + split[0] + ":" + Arrays.stream(split).skip(1).collect(Collectors.joining("_"));
        } else {
            path = "org:default:" + split[0];
        }
        return token.getAuthorities().stream().map(GrantedAuthority::getAuthority).anyMatch(path::equals);
    }
}

How is the token integrated with AuthenticationFilter?

  • The AuthenticationFilter uses the AuthenticationManager for authentication.
java
package com.juliusyolo.component;

import com.juliusyolo.exception.UserAuthenticationException;
import com.juliusyolo.model.UserModel;
import com.juliusyolo.model.UserPermissionAuthenticationToken;
import com.juliusyolo.service.UserService;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.AuthenticationException;

import java.util.Objects;

/**
 * <p>
 * UserAuthenticationManager
 * </p>
 *
 * @author julius.yolo
 * @version : UserAuthenticationManager v0.1
 */
public class UserAuthenticationManager implements AuthenticationManager {


    private final UserService userService;

    public UserAuthenticationManager(UserService userService) {
        this.userService = userService;
    }

    @Override
    public Authentication authenticate(Authentication authentication) throws AuthenticationException {
        UserPermissionAuthenticationToken userPermissionAuthenticationToken =
                (UserPermissionAuthenticationToken) authentication;
        UserModel userModel = userService.verifyToken(userPermissionAuthenticationToken);
        if (Objects.isNull(userModel)) {
            throw new UserAuthenticationException("No user found");
        }
        return UserPermissionAuthenticationToken.authenticated(userModel, userModel.getAuthorities());
    }
}

How to combine URL permissions with AuthorizationFilter?

  • AuthorizationFilter uses AuthorizationManager for permission decisions.
java
public class UserAuthorizationManager implements AuthorizationManager<RequestAuthorizationContext> {

    private final UserService userService;

    public UserAuthorizationManager(UserService userService) {
        this.userService = userService;
    }

    @Override
    public AuthorizationDecision check(Supplier<Authentication> authentication, RequestAuthorizationContext context) {
        String path = context.getRequest().getServletPath();
        UserPermissionAuthenticationToken token = (UserPermissionAuthenticationToken) authentication.get();
        boolean decision = userService.verifyAuthorization(token, path);
        return new AuthorizationDecision(decision);
    }
}

How do method security annotations perform authorization checks?

  • Spring Security method security provides annotations related to method security such as @PreAuthorize, @PostAuthorize, @PreFilter, @PostFilter, and @Secured, as well as support for security annotations in JSR250 like @RolesAllowed, @DenyAll, and @PermitAll.
  • Each annotation is associated with a corresponding method interceptor, implemented based on the principles of Spring AOP, specifically using the corresponding AuthorizationManager<MethodInvocation> to retrieve the relevant Authentication from the SecurityContext of Spring Security for permission evaluation.
  • Usage of method security annotations:
java
@Service
public class ChatService {

    @PreAuthorize("hasAuthority('org:ai:chat')")
    public String chat() {
        return "hello,world!";
    }
}

Final Outcome

Maven Dependency

Package it as a Spring Boot starter, utilizing Spring's auto-configuration capability to automatically inject the necessary components into the container. Add the following dependencies, and you can use Clerk for API authentication without any extra code, while supporting both Servlet and Reactive web applications.

xml
<dependency>
    <groupId>com.juliusyolo</groupId>
    <artifactId>endpoint-security-clerk-spring-boot-starter</artifactId>
    <version>1.0.2-RELEASE</version>
</dependency>

Configuration Explanation

properties
yolo.clerk.max-organization-count=10 #Specify the maximum number of organizations that a clerk user can have.
yolo.clerk.secret-key=${CLERK_SECRET_KEY} #Clerk application secret key.
yolo.endpoint.prefix=/api/v1 #endpoint prefix.
yolo.endpoint.permit-paths=/favicon.ico #allowed path urls.
yolo.endpoint.authorization-paths=/api/** #URL paths that require authentication and authorization.
yolo.endpoint.path-authorization-enable=false #path enables authentication switch.

Github Address

Last updated: