Integrate Stytch with Spring Security

Hi,
I’m surprised to see no entry for Java/Spring/Kotlin.
Anyhow, has anybody integrated Stytch with Spring Security?

Hey Ben – thanks for posting!

It’s relatively new, but we do actually offer a Java/ Kotlin SDK. Here are links to a Java example app and a Kotlin example app that you may find useful as well!

Regarding Spring Security, I’m not personally aware of any integrations currently using this, but it looks like there may be a way to integrate with Stytch using Spring Security’s JWT Resource Server.

It looks like Spring provides a way to set a JWKS URI, which you should be able to point at our JWKS endpoint.

Please let us know if you have any additional questions, and we’ll be happy to help! We’d also love to hear how that goes if you end up trying it out.

1 Like

Hey Nicole

Looking further into this, Spring Boot provides a library which contains JWT Resource Server dependencies to OAuth (as you posted in the link).

I’m though not sure how a OAuth based JWT resource server can hand out tokens if the user isn’t using OAuth to authenticate… Say the user uses a simple email (magic link) to sign up (using Stytch as an external authentication provider). How is this going to allow him to use OAuth for future use with a OAuth JWT resource server?

Hey Ben! My understanding is that Spring can use Stytch’s JWKS endpoint to retrieve the JWKS for your project, which it can then use to authorize requests that include a Stytch-issued JWT.

Stytch issues JWTs during every successful user authentication, regardless of which product the user used to authenticate. For example, we do return a JWT in response to our OAuth authenticate endpoint, but we also return a JWT in response to our Magic Links authenticate endpoint (and every other /authenticate endpoint).

My understanding is that Stytch would essentially act as the authorization server in this case, minting JWTs and providing the JWKS to validate them.

That said, I could certainly be mistaken about how this Spring Security feature works, as it’s not something that we’ve implemented ourselves. There may be implementation details that prevent Stytch’s JWTs and Spring Security from being compatible.

Until we try this out ourselves, we can’t guarantee that it will work as expected – but I’ve seen some interest internally about potentially creating a Spring Boot example app in the future, and I’ll +1 that on your behalf!

Please let us know if you have any additional questions that we can help out with in the meantime.

Nicole,

That makes a lot of sense - thank you!

I’ve been already working on a Spring Boot / Security integration over the weekend and I’m willing to share my code once it’s all said and done. I was just confused as to how OAuth and Magic Link users get married - but your explanation puts that to rest.

Hey Ben – awesome, very glad to hear that! Would love to check out your code when it’s finished! Please let us know if any other questions come up :slight_smile:

Nicole,

I spent some time reading up in “literature” and they talk about id_token (identification/authentication) and access_token (access to API), which is what an authorization server (Stytch) is providing.

However, I fail to tie that into your API… Either endpoint for OAuth and MagicLink provides a session_token as well as a session_jwt. But in case of MagicLinks, both of these are empty strings according to your documentation:

https://test.stytch.com/v1/magic_links/authenticate

Response:

{
“method_id”: “email-test-81bf03a8-86e1-4d95-bd44-bb3495224953”,
“request_id”: “request-id-test-b05c992f-ebdc-489d-a754-c7e70ba13141”,
“reset_sessions”: false,
“session”: null,
“session_jwt”: “”,
“session_token”: “”,
“status_code”: 200,
“user”: {…},
“user_id”: “user-test-16d9ba61-97a1-4ba4-9720-b03761dc50c6”
}

I do find an id_token and access_token within the response from OAuth’s endpoint:

https://test.stytch.com/v1/oauth/authenticate

but this establishes a session. I thought the whole point of JWT is to have stateless communication…

I guess I’m asking 2 questions:

  1. How do I find an id_token/access_token from a MagicLink response?
  2. Why does Stytch treat stateless (JWT) communication as stateful (session)?

Update:
Let me add another question:
According to the link you provided above (JWK Set Uri), besides a jwk-set-uri, one requires the issuer-uri. What is that in case of Stytch?

Hey Ben! In order to start a new Stytch session, you’ll need to specify the session_duration_minutes parameter while making your /authenticate call (for any product). Otherwise, you’ll receive empty session_token and session_jwt values, since a session will not be started.

We offer JWTs as a more performant alternative to session tokens, since JWTs can be validated locally (without contacting the Stytch API) for the duration of their lifetime (5 minutes). This reduces session authentication latency for applications that need to make frequent session authentication calls. I’d recommend checking out our blog post about session tokens vs. JWTs for some additional context!

Nicole,

When you say one needs to hit /authenticate endpoint for any product, can these products be mixed 'n matched?

Say a user doesn’t have a Google account (OAuth) and thus uses email/MagicLink to log in, can I then use the Stytch OAuth endpoint (as opposed to MagicLink endpoint) to authenticate user given the authorization code provided in MagicLink?

Hey Ben! No, the /authenticate endpoint must match the product that was used to start the authentication flow.

To make sure I understand correctly, what would the use case be for calling a different product’s /authenticate endpoint (given that all /authenticate endpoints return a session_jwt if session_duration_minutes is specified)?

Nicole,

In that case, there would not be such a use case.

The reason I was asking is because I was confused about how to offer different ways of authentication (different Stytch products) with one auth handler only. But since one needs to follow a fixed authentication flow, one needs to implement an auth handler for each offered product.
For some moment I was under the impression that an authorization code could be used for any given product…

So if I choose to offer MagicLink AND OAuth log in options, I would need to pass in a session_duration_minutes param in order to retrieve a JWT. I’m assuming this is what they refer to as id_token.

How do I then get an access_token, which will help me to determine if a user has access to my API? I know now how to validate the access_token (by using Stytch’s jwk set uri endpoint).
In other words, where is the Stytch endpoint that hands out access_token?

According to Spring doc, the authentication server should provide both, issuing and validating:

spring:
security:
oauth2:
resourceserver:
jwt:
issuer-uri: https://idp.example.com
jwk-set-uri: https://idp.example.com/.well-known/jwks.json

Hey Ben,

So if I choose to offer MagicLink AND OAuth log in options, I would need to pass in a session_duration_minutes param in order to retrieve a JWT.

To clarify, any backend /authenticate endpoint requires a session_duration_minutes parameter in order to generate a Stytch session and JWT - this is why the API response you posted earlier didn’t have a session_jwt value.

I’m assuming this is what they refer to as id_token.

Are you referring to Spring Security here?

An id_token is a standardized token returned in OAuth flows, which is why you’re seeing it in the /authenticate response for Stytch-powered OAuth flows (but not Email Magic Link flows).

How do I then get an access_token, which will help me to determine if a user has access to my API? I know now how to validate the access_token (by using Stytch’s jwk set uri endpoint).
In other words, where is the Stytch endpoint that hands out access_token?

Our understanding of Spring Boot’s role in this is something like the following:

  • Set up a JWT OAuth Resource server, pointed at the Stytch JWKS API endpoint, which will be used to validate (Stytch-issues) JWTs
  • Have your users go through a Stytch-powered authentication flow, like Email Magic Links or OAuth. In either of these cases, as long as session_duration_minutes is supplied to the /authenticate call (and the user’s authentication is successful), Stytch will generate a new session along with a Session JWT.
  • When your users need to access auth-restricted content, your application passes the Stytch-issues JWT to the Resource server, which validates the JWT using the JWKS endpoint provided earlier.

Again, we haven’t set this up ourselves with Sprint Boot specifically, but from what we can glean from Spring Boot’s documentation, I believe this is how the flow would work with Stytch!

One other thing to call out is that Stytch-issued session JWTs always have a 5 minute (300 second) lifetime, but can be exchanged for a new JWT via a /sessions/authenticate call as long as the underlying Stytch session is still valid. The reason that we set the JWT lifetime to 5 minutes is because JWTs cannot be revoked – so in order to minimize the window in which a compromised JWT could be used, we cap their lifetime. You can read more about this in our our blog post about session JWTs vs. session token!

What does your frontend looks like, or the overall flow of your application? One option to account for the 5 minute expiration is to have fallback logic such that if the JWT validation fails on your backend (due to an expired JWT), your code falls back to a /sessions/authenticate call with the expired JWT, which as long as the underlying session is still valid, will mint a new JWT that can be re-validated.

Hey Matt,

Thank you for lining out your train of thought here and I do see it the same way. For the past week I’ve been researching and playing around with how this works in Spring Security.

The examples I’ve found to date all show how to set up a Resource Server in Spring Boot / Spring Security using a JWT issuer-uri AND jwk-set-uri, where a 3rd party Authorization Server is pinged to retrieve tokens and to validate them provided the public key (through the JWKS).

In one of the forums I’ve found somebody saying one just has to not provide the issuer-uri if the tokens are received through another method (which is true in Stytch’s case), but then I read this in the Spring doc:

Consequently, Resource Server does not ping the authorization server at startup. We still specify the issuer-uri so that Resource Server still validates the iss claim on incoming JWTs.

It sounds like the validation through given jwk-set-uri happens only if the issuer-uri is provided…

To date I’ve written a MagicKey and OAuth2 Spring Security authentication provider, which consumes the Stytch JWT, which then gets embedded into the “Spring way of doing security”. I’ve not yet figured out how I can marry this with the Resource Server APIs and how the framework will validate tokens.

I’m still in the middle of researching. Again, I’m just surprised that you guys haven’t provided a tutorial how to set this up in the Spring framework. It’s not that Java/Spring is a small fish, especially in the backend world… On top of that, I’m not a security specialist and feel like I’ve now have to first become one before I can tackle this.

Long story short, I would appreciate if one of your “eat-drink-sleep” security guys would write up a Java/Kotlin/Spring/Stytch tutorial.

1 Like

Hey Ben,

We’ve let our team know about this. They’re planning to work on a Spring example at some point in the future, but we don’t have a specific ETA at the moment. Once we release that, hopefully the Spring + Stytch integration process will be clearer!

We’ll follow up on this post with any updates and a link to the example once it has been released.

1 Like

Hi,

Any updates on this? Have you found a date where you can release a Spring Boot Security and Stytch example?

Hey Ben,

Our team made a Spring Boot + Stytch Email Magic Links example app recently, but we’re still working on the Spring Security piece. It’s on our team’s radar, but I don’t have a set ETA at this point in time – sorry about that.

I’ll follow up here once we have that available!

Hey Ben,

No news at the moment, sorry about that! We’ll be sure keep this thread up to date with any updates.

Any updates? A Spring Security example would be really valuable.

Hi Alex, I figured out how to use Stytch with Spring Security 6. Haven’t had the time yet to create a GitHub public repo but maybe this will give you some insight…

I hereby also fully disclose that I’m no security expert. You are using the below code at your own risk. Feedback is welcomed.

I’m using the Java/Kotlin SDK. The basic idea is to let the backend / Spring Security handle the authentication and verification of JWTs issued by Stytch. If successful, all the backend does is issue a so called HttpOnly cookie, one for the Stytch session and one for the Stytch JWT. This prevents the client from using any JavaScript libraries to tamper with the JWT. Your frontend needs to now have a mechanism in place to renew JWTs whenever they are invalid.

A Stytch issued JWT has a max lifetime of 5 min, which you can’t change. This is because once a JWT is issued, nobody and nothing can revoke said JWT. That’s where the Stytch sessions come into play: as long as the session is alive, you will get handed out new JWTs.

@Slf4j
@Component
@ConditionalOnBean(SecurityEnabledConfig.class)
public class StytchMagicLinkAuthProvider extends StytchAuthProvider {

    @Override
    public Authentication authenticate(Authentication authentication) throws AuthenticationException {
        final String stytchAuthCode = authentication.getPrincipal().toString();
        // let Stytch do it's magic
        // in order to receive Stytch session JWT -> pass in session_duration_minutes
        final AuthenticateRequest request = new AuthenticateRequest(stytchAuthCode, null, null, null,10080); // 1 week lifetime
        final StytchResult<AuthenticateResponse> response;
        final Instant priorToStytchJwtInception = Instant.now(); // capture instant prior to retrieving Stytch JWT
        try {
            response = StytchClient.magicLinks.authenticateCompletable(request).get(); // thread awaits response
        } catch (InterruptedException | ExecutionException e) {
            log.error(e.getMessage(), e);
            throw new InternalAuthenticationServiceException(e.getMessage());
        }
        if (response instanceof StytchResult.Error) {
            var exception = ((StytchResult.Error) response).getException();
            log.warn(Objects.requireNonNull(exception.getReason()).toString());
            throw new AuthenticationServiceException(exception.getMessage());
        } else {
            final AuthenticateResponse authResponse = ((StytchResult.Success<AuthenticateResponse>) response).getValue();
            final User stytchUser = authResponse.getUser();
//         EITHER: retrieve Stytch session JWT and then use JWK Set URI to validate token
//         OR: build JWTs detached from Stytch -> my backend issues tokens
            final String stytchSessionToken = authResponse.getSessionToken(); // typically 24h but we do 1 week
            assert authResponse.getSession() != null;
            final Instant stytchSessionExpiresAt = authResponse.getSession().getExpiresAt();
            // retrieve JWT from Stytch session
            final String stytchJwtToken = authResponse.getSessionJwt(); // JWT default lifetime is 5 min -> since JWTs can't be revoked
            return doAuthResponse(stytchUser, stytchSessionToken, stytchSessionExpiresAt, stytchJwtToken, priorToStytchJwtInception);
        }
    }

    @Override
    public boolean supports(Class<?> authentication) {
        return authentication.equals(PreAuthenticatedAuthenticationToken.class);
    }

}
@Slf4j
public abstract class StytchAuthProvider implements AuthenticationProvider {

    @Autowired
    private JwtDecoder jwtDecoder;
    @Autowired
    private ModelMapper modelMapper;
    @Autowired
    private UserRepository userRepository;
    @Autowired
    private AccountRepository accountRepository;

    protected Authentication doAuthResponse(
            final com.stytch.java.consumer.models.users.User stytchUser,
            final String stytchSessionToken,
            final Instant stytchSessionExpiresAt,
            final String stytchJwtToken,
            final Instant priorToStytchJwtInception) {
        // extract user data
        final String stytchUserId = stytchUser.getUserId(); // we treat stytchUserId as Spring Security username
        assert stytchUser.getName() != null;
        final String stytchUserFullName = stytchUser.getName().getFirstName() + " " + stytchUser.getName().getLastName();
        final Map<String, Object> stytchUserTrustedMetadata = stytchUser.getTrustedMetadata();
        // derive Stytch role, maybe even passed from your RBAC?
        assert stytchUserTrustedMetadata != null;
        final String role = (String) stytchUserTrustedMetadata.getOrDefault("role", "user");
        final Set<GrantedAuthority> authorities = Set.of(new SimpleGrantedAuthority(role));
        log.debug(format("User %s with Stytch user id %s authenticated", stytchUserFullName, stytchUserId));

        // A) if using external JWTs issuer, such as Stytch:
        if (StringUtils.isBlank(stytchJwtToken)) {
            throw new AuthenticationServiceException("Can't authenticate - missing JWT");
        }
        // convert Stytch JWT into Spring Security JWT
        final Jwt jwt = jwtDecoder.decode(stytchJwtToken);

        User user;
        if (isUserRegistered(jwt)) { // if user exists -> user has already been registered and account associated before
            user = modelMapper.map(jwt, User.class);
        } else {
            final Optional<com.stytch.java.consumer.models.users.Email> optionalEmail = stytchUser.getEmails().stream().findFirst();
            if (optionalEmail.isPresent()) {
                final String email = optionalEmail.get().getEmail();
                // if user doesn't exist at first -> check if user's email is parked as an account associate
                final Optional<Account> optionalAccount = accountRepository.findByAssociatesContainingIgnoreCase(email);
                if (optionalAccount.isPresent()) {
                    // create new user and associate with account
                    final Account account = optionalAccount.get();
                    user = createUser(stytchUser, account, Set.of(role));
                    // now that new user is associated with an existing account -> remove email from associations
                    account.removeAssociate(email);
                } else { // we have a new account owner -> register as new user and owner of the account
                    // if user doesn't exist anywhere -> create a brand new account and make user the account holder/owner
                    user = createUser(stytchUser, createDummyAccount(stytchUserFullName), Set.of(role));
                }
            } else {
                throw new IllegalStateException("Stytch didn't provide an email");
            }
        }

        final String principalClaimValue = jwt.getClaimAsString(StytchToken.PRINCIPAL_CLAIM_NAME.getName());
        return new StytchAuthToken(stytchSessionToken, stytchSessionExpiresAt, user, jwt, authorities, principalClaimValue);

        // B) if issuing own JWTs:
        // we need to use a fully initialized UPA token in order to have the authenticated flag set
        // password is set to Stytch JWT
        // return new UsernamePasswordAuthenticationToken(stytchUserId, stytchJwt, authorities);
    }
}
@Slf4j
@EqualsAndHashCode(callSuper = false)
@Getter
public final class StytchAuthToken extends JwtAuthenticationToken {

    private final String sessionToken; // Stytch session token
    private final Instant sessionExpiresAt;
    private final User user;

    public StytchAuthToken(
            @NotNull String sessionToken,
            @NotNull Instant sessionExpiresAt,
            @NotNull User user,
            @NotNull Jwt jwt, // contains the Stytch JWT token
            @NotNull Collection<? extends GrantedAuthority> authorities,
            @NotBlank String name
    ) {
        super(jwt, authorities, name);
        this.sessionToken = sessionToken;
        this.sessionExpiresAt = sessionExpiresAt;
        this.user = user;
        this.setAuthenticated(true);
    }

    public String getJwtToken() {
        return this.getToken().getTokenValue();
    }

    public Instant getJwtExpiresAt() {
        return this.getToken().getExpiresAt();
    }
}
@Slf4j
@Component
@ConditionalOnBean(SecurityEnabledConfig.class)
@RequiredArgsConstructor
public class StytchAuthConverter implements Converter<Jwt, AbstractAuthenticationToken> {

    private final Converter<Jwt, Collection<GrantedAuthority>> jwtGrantedAuthoritiesConverter = new JwtGrantedAuthoritiesConverter();
    private final ModelMapper modelMapper;

    @Override
    public final AbstractAuthenticationToken convert(@NotNull Jwt jwt) { // validated jwt token from Authorization Server (Stytch)
        final Collection<GrantedAuthority> authorities = this.jwtGrantedAuthoritiesConverter.convert(jwt);
//        jwtAuthenticationConverter.setJwtGrantedAuthoritiesConverter(grantedAuthoritiesConverter);
        final String principalClaimValue = jwt.getClaimAsString(StytchToken.PRINCIPAL_CLAIM_NAME.getName());

        // 1. Get cookies from the request
        final HttpServletRequest request = ((ServletRequestAttributes) Objects.requireNonNull(RequestContextHolder.getRequestAttributes())).getRequest();
        final Cookie[] cookies = request.getCookies();

        // 2. Find the relevant cookies
        Cookie sessionToken = null;
        if (cookies != null) {
            sessionToken = Arrays.stream(cookies)
                    .filter(c -> c.getName().equals(StytchToken.SESSION_TOKEN.getName()))
                    .findFirst()
                    .orElse(null);
        }

        // JWT validation is handled already, now make sure we get to keep the session token

        // 3. make sure Stytch session is available and valid
        if (sessionToken != null) {
            AuthenticateRequest stytchAuthenticateRequest = new AuthenticateRequest(sessionToken.getValue());
            StytchResult<AuthenticateResponse> stytchAuthenticateResponse;
            try {
                stytchAuthenticateResponse = StytchClient.sessions.authenticateCompletable(stytchAuthenticateRequest).get();
            } catch (InterruptedException | ExecutionException e) {
                throw new BadJwtException("Invalid session token");
            }
            if (stytchAuthenticateResponse instanceof StytchResult.Error) {
                SecurityContextHolder.getContext().setAuthentication(null);
                throw new BadJwtException("Invalid session token");
            }
            else {
                final User user = modelMapper.map(jwt, User.class);
                final AuthenticateResponse authResponse =
                        ((StytchResult.Success<AuthenticateResponse>) stytchAuthenticateResponse).getValue();
                return new StytchAuthToken(authResponse.getSessionToken(), authResponse.getSession().getExpiresAt(),
                        user, jwt, authorities, principalClaimValue);
            }
        } else {
            // TODO: should we redirect user to login instead of throwing error?
            throw new BadJwtException("Session token not found in cookies");
        }
    }

}
@Component
@ConditionalOnBean(SecurityEnabledConfig.class)
@Order(Ordered.LOWEST_PRECEDENCE) // Ensure it runs after other filters
public class StytchResponseEnrichmentFilter implements Filter {

    @Value("${root.domain:localhost}")
    private String domain;

    @Override
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {

        chain.doFilter(request, response); // process the security filter chain first

        final Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
        if (authentication instanceof StytchAuthToken authToken) {
            HttpServletResponse r = (HttpServletResponse) response;
            // since JWT is available now -> instruct caller to start using cookies and subsequently,
            // ensure security filter chain works for incoming requests
            final ResponseCookie sessionTokenCookie =
                    CookieUtil.createCookie(StytchToken.SESSION_TOKEN.getName(), authToken.getSessionToken(),
                            domain, authToken.getSessionExpiresAt());
            r.addHeader(HttpHeaders.SET_COOKIE, sessionTokenCookie.toString());

            final ResponseCookie jwtTokenCookie =
                    CookieUtil.createCookie(StytchToken.JWT_TOKEN.getName(), authToken.getJwtToken(),
                            domain, authToken.getJwtExpiresAt());
            r.addHeader(HttpHeaders.SET_COOKIE, jwtTokenCookie.toString());
        }
    }
}
@Slf4j
public final class CookieUtil {

    public static ResponseCookie createCookie(
            @NotBlank final String name,
            @NotBlank final String token,
            @NotBlank final String domain,
            @NotNull final Instant expiresAt
    ) {
        Duration delta = Duration.between(Instant.now(), expiresAt);
        if (delta.isNegative()) {
            delta = Duration.ZERO; // explicitly set to 0 -> delete immediately
        }

        return ResponseCookie.from(name, token)
                .httpOnly(true) // prevent client-side JS from accessing cookie, protect against XSS attacks
                .secure(true) // set Secure flag (if using HTTPS)
                .sameSite("None")
                .domain(domain)
                .path("/")
                .maxAge(delta)
                .build()
                ;
    }

    public static String extractSessionToken(@NotNull HttpServletRequest request) {
        return Optional.ofNullable(request.getCookies())
                .flatMap(cookies -> Arrays.stream(cookies)
                        // Per documentation Stytch requires either the session token OR the jwt token - not both!
                        .filter(cookie -> StytchToken.SESSION_TOKEN.getName().equals(cookie.getName()))
                        .findFirst())
                .map(Cookie::getValue)
                .orElse(null);
    }

    public static String extractJwtToken(@NotNull HttpServletRequest request) {
        return Optional.ofNullable(request.getCookies())
                .flatMap(cookies -> Arrays.stream(cookies)
                        // Per documentation Stytch requires either the session token OR the jwt token - not both!
                        .filter(cookie -> StytchToken.JWT_TOKEN.getName().equals(cookie.getName()))
                        .findFirst())
                .map(Cookie::getValue)
                .orElse(null);
    }

}
@Configuration
@Profile("security-enabled")
@RequiredArgsConstructor
public class JwtConfig {

    @Value("${stytch.iss:stytch.com/project-test-YOURPROJECTCODE}")
    private String iss;
    private final RestTemplate restTemplate;

    @Bean
    public JwtDecoder jwtDecoder(OAuth2ResourceServerProperties properties) {
        final NimbusJwtDecoder jwtDecoder = NimbusJwtDecoder
                .withJwkSetUri(properties.getJwt().getJwkSetUri())
                .jwtProcessorCustomizer(customizer -> customizer
                        .setJWSTypeVerifier(new DefaultJOSEObjectTypeVerifier<>(
                                // FIXME: Stytch is configured to use less secure JWT
                                new JOSEObjectType("JWT"), // allow (less secure) JWT (used by Stytch)
                                new JOSEObjectType("at+jwt") // also allow (more secure) at+jwt for Nimbus (default since v9)
                        ))
                )
                .restOperations(restTemplate)
                .build();

        // Resource Server configures a 60s clock skew by default
        OAuth2TokenValidator<Jwt> withClockSkew = new DelegatingOAuth2TokenValidator<>(
                new JwtTimestampValidator(Duration.ofSeconds(60)),
                new JwtIssuerValidator(iss)
//                new JwtIssuerValidator(properties.getJwt().getJwkSetUri()) // this uri is wrong
        );
        jwtDecoder.setJwtValidator(withClockSkew);

        MappedJwtClaimSetConverter converter = MappedJwtClaimSetConverter
                .withDefaults(Collections.emptyMap());
//                .withDefaults(Collections.singletonMap("sub", this::lookupUserIdBySub));
        jwtDecoder.setClaimSetConverter(converter);

        return jwtDecoder;
    }

}
@Configuration
@EnableWebSecurity
@Profile("security-enabled")
@RequiredArgsConstructor
public class SecurityEnabledConfig {

    private final AuthenticationProvider stytchMagicLinkAuthProvider;

    @Value("${allowed.origin:https://localhost:9000}")
    private String allowedOrigin;

    @Value("${allowed.methods:GET,POST,PUT,DELETE,OPTIONS}")
    private String allowedMethods;

    @Value("${allowed.headers:Content-Type,Accept,Authorization,loggedInUserId,Cache-Control,Pragma,Expires,Access-Control-Allow-Headers,X-Requested-With}")
    private String allowedHeaders;

    private final CookieBearerTokenResolver cookieBearerTokenResolver;
    private final JwtDecoder jwtDecoder;
    private final Converter<Jwt, AbstractAuthenticationToken> stytchAuthConverter;
    private final StytchResponseEnrichmentFilter stytchResponseEnrichmentFilter;

    @Bean
    public SecretKey jwtSecretKey() {
        return Keys.secretKeyFor(SignatureAlgorithm.HS512);
    }

    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }

    @Bean
    public SecurityContextRepository securityContextRepository() {
        return new RequestAttributeSecurityContextRepository();
    }

    @Bean
    public SecurityFilterChain filterChain(
            HttpSecurity http,
            HandlerMappingIntrospector introspector
    ) throws Exception {
        http
                .cors(cors -> cors.configurationSource(corsConfigurationSource()))
                .csrf(AbstractHttpConfigurer::disable) // FIXME: needs to be configured
                .httpBasic(HttpBasicConfigurer::disable)
                // optional parent in case no AuthenticationProvider is present
//                .authenticationManager(authenticationConfiguration.getAuthenticationManager()) // optional parent
//                .authenticationProvider(oAuth2AuthProvider) // TODO: enable this as soon as OAuth2 is a need
                .authenticationProvider(stytchMagicLinkAuthProvider)
                .authorizeHttpRequests(authorize -> authorize // all API related endpoints
                                .requestMatchers(new MvcRequestMatcher(introspector, "/api/auth/login-or-create/**")).permitAll()
                                .requestMatchers(new MvcRequestMatcher(introspector, "/api/auth/persist-user/**")).permitAll()
                                .requestMatchers(new MvcRequestMatcher(introspector, "/api/auth/authenticate/**")).permitAll()
                                .requestMatchers(new MvcRequestMatcher(introspector, "/api/auth/verify/**")).permitAll()
                                .requestMatchers(new MvcRequestMatcher(introspector, "/api/auth/whoami/**")).permitAll()
                                .requestMatchers(new MvcRequestMatcher(introspector, "/api/auth/refresh/**")).permitAll()
                                .requestMatchers(new MvcRequestMatcher(introspector, "/swagger-ui/**")).permitAll()
                                .requestMatchers(new MvcRequestMatcher(introspector, "/v3/api-docs/**")).permitAll()
                                .requestMatchers(new MvcRequestMatcher(introspector, "/actuator/**")).permitAll() // TODO: .hasRole("ADMIN")
                                .requestMatchers(new MvcRequestMatcher(introspector, "/.well-known/acme-challenge/**")).permitAll()
                                .anyRequest().authenticated()
                )
                // don't use backend sessions -> Stytch is handling sessions for us
                .sessionManagement(session -> session
                        .sessionCreationPolicy(SessionCreationPolicy.STATELESS)
                )
                // manage storage and retrieval of the SecurityContext -> use attribute within each http request
                .securityContext(sc -> sc
                        .securityContextRepository(securityContextRepository())
                )
                // Resource Server -> backend
                // Authorization Server -> Stytch
                // configure this application to act as a OAuth2 resource server and validate tokens against JWKS endpoint
                // ONLY DOES JWT VALIDATION, NOT JWT RENEWAL
                .oauth2ResourceServer(oauth2 -> {
                        // 1. extract JWT from cookie while handling incoming http request
                        // Per documentation Stytch requires either the session token OR the jwt token - not both!
                        oauth2.bearerTokenResolver(cookieBearerTokenResolver);
                        // 2. use JwtDecoder to decode jwt and verify signature -> already configured as a bean
                        // 3. use jwt() for claim and expiration validation
                        // convert validated JWT from authorization server into authentication object, which then
                        // gets persisted into the SecurityContext further down the filter chain
//                        .jwt(Customizer.withDefaults()) // creates JwtAuthenticationToken
                        oauth2.jwt(jwt -> jwt
                                .decoder(jwtDecoder)
                                .jwtAuthenticationConverter(stytchAuthConverter)
                        );
                })
                // handle authentication failures which should redirect to some sort of login screen
//                .exceptionHandling(exceptions -> exceptions
//                        // handles authentication failures such as missing token or credentials
//                        // do NOT handle expired JWTs here!
//                        .authenticationEntryPoint(new BearerTokenAuthenticationEntryPoint()) // FIXME: we are using cookies -> write own one
//                        .accessDeniedHandler(new BearerTokenAccessDeniedHandler()) // FIXME: dito
//                        .accessDeniedPage("/error/403")
//                )
//                .exceptionHandling(Customizer.withDefaults()) //TODO: .authenticationEntryPoint(unauthorizedHandler)
                // make sure we always enrich the http response with our session token and jwt token cookies
                // once authentication is confirmed
                .addFilterAfter(stytchResponseEnrichmentFilter, AuthorizationFilter.class)
        ;
        return http.build();
    }

    @Bean
    public UrlBasedCorsConfigurationSource corsConfigurationSource() {
        final CorsConfiguration configuration = new CorsConfiguration();
        configuration.setAllowedOrigins(List.of(allowedOrigin));
        configuration.setAllowedMethods(List.of(allowedMethods));
        configuration.setAllowedHeaders(List.of(allowedHeaders));
        configuration.setAllowCredentials(true); // a must for client Authorization headers and HttpOnly cookies
        final UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
        source.registerCorsConfiguration("/api/**", configuration); // apply cors to API endpoints only
        return source;
    }

}
@Component
@ConditionalOnBean(SecurityEnabledConfig.class)
public class CookieBearerTokenResolver implements BearerTokenResolver {

    @Override
    public String resolve(HttpServletRequest request) {
        return CookieUtil.extractJwtToken(request);
    }

}

This here is the Magic Link controller, which I couldn’t fit into my previous reply. Some of the info you get from looking into your Stytch dashboard:

@Tag(name = "Authentication Management")
@Slf4j
@RestController
@ConditionalOnBean(SecurityEnabledConfig.class)
@RequestMapping("/api/auth")
public class StytchMagicLinkController {

    private final AuthenticationManager authenticationManager;
    private final AccountRepository accountRepository;
    private final UserRepository userRepository;
    private final JwtDecoder jwtDecoder;
    private final Environment environment;
    private final ModelMapper modelMapper;
    @Value("${app.redirect.uri:https://localhost:9000/login/callback}")
    private String redirectUri;
    @Value("${root.domain:localhost}")
    private String domain;

    public StytchMagicLinkController(
            AuthenticationConfiguration authenticationConfiguration,
            AccountRepository accountRepository,
            UserRepository userRepository,
            JwtDecoder jwtDecoder,
            Environment environment,
            ModelMapper modelMapper) throws Exception {
        this.authenticationManager = authenticationConfiguration.getAuthenticationManager();
        this.accountRepository = accountRepository;
        this.userRepository = userRepository;
        this.jwtDecoder = jwtDecoder;
        this.environment = environment;
        this.modelMapper = modelMapper;
    }

    @PostConstruct
    public void init() {
        StytchClient.configure(
                Objects.requireNonNull(environment.getProperty("stytch.project-id")),
                Objects.requireNonNull(environment.getProperty("stytch.secret"))
        );
    }

    @PostMapping(
            value = "/login-or-create"
            , consumes = MediaType.APPLICATION_JSON_VALUE
    )
    @PreAuthorize("isAnonymous()")
    public ResponseEntity<String> loginOrCreate(@Valid @RequestBody MyLoginOrCreateRequest locRequest) throws ExecutionException, InterruptedException {
        LoginOrCreateRequest request = new LoginOrCreateRequest(
                locRequest.getEmail(),
                environment.getProperty("app.authenticate.uri"),
                environment.getProperty("app.authenticate.uri"),
                null,
                null,
                "whatevernameyouspecifiedinstytch_login",
                null,
                null,
                null,
                null,
                null
        );
        StytchResult<LoginOrCreateResponse> response = StytchClient.magicLinks.getEmail().loginOrCreateCompletable(request).get();
        if (response instanceof StytchResult.Error) {
            var exception = ((StytchResult.Error) response).getException();
            log.error(Objects.requireNonNull(exception.getReason()).toString());
            return ResponseEntity.badRequest().body(exception.getReason().toString());
        }
        if (response instanceof StytchResult.Success<?>) {
            log.info(((StytchResult.Success<?>) response).getValue().toString());
            return ResponseEntity.ok("Check your email inbox");
        }
        return ResponseEntity.internalServerError().build();
    }

    @GetMapping("/authenticate")
    @PreAuthorize("isAnonymous()")
    public ResponseEntity<?> authenticate(@NotNull HttpServletResponse response, @RequestParam("token") String stytchAuthCode) {
        // since we are pre authentication and poses a Stytch auth code only -> use PAA token with principal set to Stytch token
        // this is consistent with oauth2's use of JWTs as Spring Security principals
        final Authentication authentication = authenticationManager.authenticate(new PreAuthenticatedAuthenticationToken(stytchAuthCode, null));
        SecurityContextHolder.getContext().setAuthentication(authentication); // thread local -> just valid for this one thread

        // since JWT is available now -> instruct caller to start using cookies and subsequently,
        // ensure security filter chain works for incoming requests
        final StytchAuthToken authToken = (StytchAuthToken) authentication;

        final ResponseCookie sessionTokenCookie =
                CookieUtil.createCookie(StytchToken.SESSION_TOKEN.getName(), authToken.getSessionToken(),
                        domain, authToken.getSessionExpiresAt());
        response.addHeader(HttpHeaders.SET_COOKIE, sessionTokenCookie.toString());

        final ResponseCookie jwtTokenCookie =
                CookieUtil.createCookie(StytchToken.JWT_TOKEN.getName(), authToken.getJwtToken(),
                        domain, authToken.getJwtExpiresAt());
        response.addHeader(HttpHeaders.SET_COOKIE, jwtTokenCookie.toString());

        // now build a redirect to the client callback
        final UriComponentsBuilder builder = UriComponentsBuilder.fromUriString(redirectUri);
        final HttpHeaders headers = new HttpHeaders();
        headers.setLocation(builder.build().toUri()); // set client callback location
        // WARNING: NEVER include auth header in our http requests -> secure HttpOnly cookie is best practice!

        // 302 GET redirect, so the client (browser) redirects to callback location (frontend/axios)
        return new ResponseEntity<>(headers, HttpStatus.FOUND);
    }

    @GetMapping("/whoami")
//    @PreAuthorize("isAuthenticated()")
    public ResponseEntity<?> getCurrentUser(@AuthenticationPrincipal Authentication authentication) {
		// FIXME: this doesn't work -> need to get back jwt
        final StytchAuthToken stytchAuthToken = (StytchAuthToken) authentication;
        if (stytchAuthToken.getPrincipal() instanceof Jwt jwt) {
            final User user = stytchAuthToken.getUser();
            log.debug("User: " + user.getUsername() + " Email: " + user.getEmails().stream().findFirst());
            return ResponseEntity.ok(modelMapper.map(user, UserDto.class));
        } else {
            return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
                    .header("X-Auth-Error", StytchToken.SESSION_EXPIRED.getName())
                    .body("Not authenticated");
        }
    }

    @GetMapping("/refresh")
    @PreAuthorize("isAnonymous()") // since JWT is invalid we have to allow anonymous
    public ResponseEntity<?> refreshToken(HttpServletRequest request, HttpServletResponse response) {
        // 1. extract session token from cookie
        final String sessionToken = CookieUtil.extractSessionToken(request);
        if (StringUtils.isBlank(sessionToken)) {
            return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
                    .header("X-Auth-Error", StytchToken.SESSION_EXPIRED.getName())
                    .body("Session token not found");
        }

        try {
            // 2. authenticate session token with Stytch and then get a fresh Jwt
            final AuthenticateRequest stytchAuthenticateRequest = new AuthenticateRequest(sessionToken);
            final Instant priorToStytchJwtInception = Instant.now(); // capture instant prior to retrieving Stytch JWT
            final StytchResult<AuthenticateResponse> stytchAuthenticateResponse =
                    StytchClient.sessions.authenticateCompletable(stytchAuthenticateRequest).get();
            if (stytchAuthenticateResponse instanceof StytchResult.Error) {
                // create cookies with maxAge = 0 -> delete immediately
                final ResponseCookie sessionTokenCookie =
                        CookieUtil.createCookie(StytchToken.SESSION_TOKEN.getName(), sessionToken, domain, Instant.now());
                response.addHeader(HttpHeaders.SET_COOKIE, sessionTokenCookie.toString());

                final String jwtToken = CookieUtil.extractJwtToken(request);
                if (!jwtToken.isBlank()) {
                    final ResponseCookie jwtTokenCookie =
                            CookieUtil.createCookie(StytchToken.JWT_TOKEN.getName(), jwtToken, domain, Instant.now());
                    response.addHeader(HttpHeaders.SET_COOKIE, jwtTokenCookie.toString());
                }

                return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
                        .header("X-Auth-Error", StytchToken.SESSION_EXPIRED.getName())
                        .body("Invalid or expired session token. Please log in again");
            } else {
                AuthenticateResponse authResponse =
                        ((StytchResult.Success<AuthenticateResponse>) stytchAuthenticateResponse).getValue();

                // WARNING: NEVER include auth header in our http requests -> secure HttpOnly cookie is best practice!
                final ResponseCookie sessionTokenCookie =
                        CookieUtil.createCookie(StytchToken.SESSION_TOKEN.getName(), authResponse.getSessionToken(),
                                domain, authResponse.getSession().getExpiresAt());
                response.addHeader(HttpHeaders.SET_COOKIE, sessionTokenCookie.toString());

                final ResponseCookie jwtTokenCookie =
                        CookieUtil.createCookie(StytchToken.JWT_TOKEN.getName(), authResponse.getSessionJwt(),
                                domain, priorToStytchJwtInception.plusSeconds(300)); // Stytch Jwt max lifetime is min
                response.addHeader(HttpHeaders.SET_COOKIE, jwtTokenCookie.toString());

                return ResponseEntity.ok().build();
            }
        } catch (Exception e) {
            // Handle other errors (e.g., network issues)
            return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body("Error during token refresh");
        }
    }

    @PostMapping("/persist-user")
    @PreAuthorize("isAuthenticated()")
    public void persistUser(@Valid @RequestBody PersistUserRequest persistUserRequest) {
        // either find existing user or create new
        userRepository.findByStytchUserId(persistUserRequest.getStytchUserId()).ifPresentOrElse(u -> {
            log.info("User already available - no need to persist");
        }, () -> {
            registerUser(new SignupRequest(persistUserRequest.getEmail(), persistUserRequest.getStytchUserId(), Collections.emptySet()));
        });
    }

    @GetMapping("/logout")
    @PreAuthorize("isAuthenticated()")
    public ResponseEntity<String> logout(
            @NotNull HttpServletResponse response
    ) {
        final Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
        if (authentication instanceof StytchAuthToken authToken) {
            try {
                final RevokeRequest revokeRequest = new RevokeRequest(null, authToken.getSessionToken(), authToken.getJwtToken());
                // kill entire Stytch session
                StytchClient.sessions.revokeCompletable(revokeRequest).get();// blocking thread
            } catch (InterruptedException | ExecutionException ex) {
                log.error(ex.getMessage(), ex);
            }
            // ensure that cookies are being deleted
            final ResponseCookie sessionTokenCookie =
                    CookieUtil.createCookie(StytchToken.SESSION_TOKEN.getName(), authToken.getSessionToken(),
                            domain, Instant.now());
            response.addHeader(HttpHeaders.SET_COOKIE, sessionTokenCookie.toString());

            final ResponseCookie jwtTokenCookie =
                    CookieUtil.createCookie(StytchToken.JWT_TOKEN.getName(), authToken.getJwtToken(),
                            domain, Instant.now());
            response.addHeader(HttpHeaders.SET_COOKIE, jwtTokenCookie.toString());
        }
        SecurityContextHolder.getContext().setAuthentication(null);
        return ResponseEntity.ok("loggedOut");
    }

   
    private ResponseEntity<?> registerUser(@Valid @RequestBody SignupRequest signUpRequest) {
        final User user = new User();
        user.addEmail(signUpRequest.getEmail());
        user.setStytchUserId(signUpRequest.getStytchUserId());
        // create new user's account
        final Account account = new Account(user);
        // associate new account with user
        user.setAccount(account);
        userRepository.save(user);
        accountRepository.save(account);
        return ResponseEntity.ok(new MessageResponse("User registered successfully!"));
    }

}