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
Each request carries the
Clerk
login token, transmitted through the request header as aBearer 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 theClerk
token represents a valid session. Upon successful authentication, it retrieves all organization role permissions related to the user, constructs the domain modelAuthentication
, 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 userAuthentication
credentials.
Based on the Spring Method Security interceptor logic, the user
Authentication
credentials stored inSecurityContext
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
Specific Implementation
How to verify the validity of the Clerk
token?
- The
Java backend API
provided byClerk
is used to verify the validity of user tokens and retrieve the user's organization role permissions.
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 theAuthenticationManager
for authentication.
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
usesAuthorizationManager
for permission decisions.
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 correspondingAuthorizationManager<MethodInvocation>
to retrieve the relevantAuthentication
from theSecurityContext
ofSpring Security
for permission evaluation. - Usage of method security annotations:
@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.
<dependency>
<groupId>com.juliusyolo</groupId>
<artifactId>endpoint-security-clerk-spring-boot-starter</artifactId>
<version>1.0.2-RELEASE</version>
</dependency>
Configuration Explanation
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.