CSRF for Stateless SSO APIs

Posted on June 4, 2022
Tags: spring, java

TL;DR

In Spring CSRF generates a new token for each new session. If you have http.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS) then there is a new session triggered for each request. This means we need a different mechanism to calculate the CSRF token, otherwise it’ll be rotated with every request, and you’ll end up with a race condition where your UI will have its token changed mid-request (at least in Angular 12). The solution here is to derive a hash from the SSO session.

Problem

CSRF solves the problem of malicious websites causing the user’s browser to make unauthorised requests to your application. It does not solve a man-in-the-middle situation, either for credential stealing, or payload manipulation.

Cross-Site Request Forgery Prevention

In Spring the CSRF token is generated/rotated when the user authenticates, and this is stored in a cookie that is sent to the user. However, if you are using a true stateless service then each request will trigger this, and the token will change with each request. When you have a large number of concurrent calls occurring from the client one call may return and update the CSRF token cookie after another call has set the CSRF header, but before it is sent, so it ends up being sent with the old CSRF header, and the new CSRF cookie. This has been observed in Angular 12.

Solution

The solution here is to have the CSRF token be derived from the SSO session. In this case we’re using Keycloak. I’m not a security expert, nor an OAUTH expert, so I don’t know how securely the SSO session ID should be kept, so in this solution we use a hash function to derive the CSRF token to avoid revealing the session ID.

The session trigger is handled by SessionAuthenticationStrategy. In this example I generate a SHA-512 hash of the token, then truncate it because 512 bits is over the top for our purposes.

The concern is the cost to generate this hash. From what I’ve read SHA-512 is faster than SHA-256 on 64-bit machines, but I have not verified this. Also, something far faster, but less cryptographically secure like MD5 may be sufficient. Advise on this is welcome in the comments.

public class KeycloakSessionCsrfAuthenticationStrategy implements SessionAuthenticationStrategy {

    static final String DEFAULT_CSRF_PARAMETER_NAME = "_csrf";

    static final String DEFAULT_CSRF_HEADER_NAME = "X-XSRF-TOKEN";

    private String parameterName = DEFAULT_CSRF_PARAMETER_NAME;

    private String headerName = DEFAULT_CSRF_HEADER_NAME;

    private final Log logger = LogFactory.getLog(getClass());

    private final CsrfTokenRepository csrfTokenRepository;

    public KeycloakSessionCsrfAuthenticationStrategy(CsrfTokenRepository csrfTokenRepository) {
        Assert.notNull(csrfTokenRepository, "csrfTokenRepository cannot be null");
        this.csrfTokenRepository = csrfTokenRepository;
    }

    @Override
    public void onAuthentication(Authentication authentication, HttpServletRequest request,
            HttpServletResponse response) throws SessionAuthenticationException {
        CsrfToken currentToken = this.csrfTokenRepository.loadToken(request);

        Optional<String> maybeSessionId = getSessionIdHash(authentication);

        if (!isFromCurrentSession(currentToken, maybeSessionId)) {
            CsrfToken newToken = generateToken(maybeSessionId);
            this.csrfTokenRepository.saveToken(null, request, response);
            this.csrfTokenRepository.saveToken(newToken, request, response);
            request.setAttribute(CsrfToken.class.getName(), newToken);
            request.setAttribute(newToken.getParameterName(), newToken);
            this.logger.debug("Replaced CSRF Token");
        }
    }

    private boolean isFromCurrentSession(CsrfToken currentToken, Optional<String> maybeSessionId) {
        if (currentToken == null) {
            return false;
        }

        return maybeSessionId
                .map(sessionId -> currentToken.getToken().startsWith(sessionId))
                .orElse(false);
    }

    private CsrfToken generateToken(Optional<String> maybeSessionId) {
        String token = maybeSessionId
                .orElseGet(() -> UUID.randomUUID().toString());

        return new DefaultCsrfToken(this.headerName, this.parameterName, token);
    }

    /**
     * Create a simple hash from the Session ID. Leaking the session ID is probably
     * not a problem
     * but this is not for certain, so we protect it anyway.
     *
     * We return a short version because the full version is over the top.
     * 
     * @param authentication hopefully a KeycloakAuthenticationToken
     * @return optional of the session id hash
     */
    private Optional<String> getSessionIdHash(Authentication authentication) {
        MessageDigest md;
        try {
            md = MessageDigest.getInstance("SHA-512");
        } catch (NoSuchAlgorithmException e) {
            throw new AssertionError("No SHA-512");
        }

        return Optional.ofNullable(authentication)
                .map(Authentication::getDetails)
                .filter(details -> details instanceof OidcKeycloakAccount)
                .map(d -> ((OidcKeycloakAccount) d).getKeycloakSecurityContext())
                .map(KeycloakSecurityContext::getToken)
                .map(AccessToken::getSessionId)
                .map(sessionId -> convertToHex(md.digest(sessionId.getBytes())))
                .map(hash -> hash.substring(0, 30));
    }

    private String convertToHex(final byte[] messageDigest) {
        BigInteger bigint = new BigInteger(1, messageDigest);
        String hexText = bigint.toString(16);
        while (hexText.length() < 32) {
            hexText = "0".concat(hexText);
        }
        return hexText;
    }

This is then registered as part of the CSRF

class WebAppSecurity extends KeycloakWebSecurityConfigurerAdapter {
    ...
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        CsrfTokenRepository csrfTokenRepository = CookieCsrfTokenRepository.withHttpOnlyFalse();

        http.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
            .and().csrf(csrf -> csrf.csrfTokenRepository(csrfTokenRepository)
                .sessionAuthenticationStrategy(new KeycloakSessionCsrfAuthenticationStrategy(csrfTokenRepository));
    }
}

Conclusion

As mentioned, I’m not a security expert, so there may be better, more efficient ways of handling the token generation from the session ID. Of most interest is a deeper understanding of how CSRF operates in Spring.