Skip to content

One-time token login does not set authentication details, and authenticationDetailsSource has no effect #19863

Description

@jsunsoftware

Describe the bug

After a one-time token login, Authentication#getDetails() is null. Form login, HTTP Basic, bearer tokens and
OAuth2 login all set WebAuthenticationDetails (remote address and session id). One-time token login does not, and
there is no supported way to turn it on:

  • OneTimeTokenAuthenticationConverter creates the OneTimeTokenAuthenticationToken from the token parameter only
    and sets no details
    (source).
  • OneTimeTokenAuthenticationFilter authenticates through AbstractAuthenticationProcessingFilter#attemptAuthentication,
    which converts the request and authenticates it but never applies the filter's authenticationDetailsSource
    (source).
  • Because of that, oneTimeTokenLogin((ott) -> ott.authenticationDetailsSource(...)) is accepted and passed to the
    filter
    (source),
    but changes nothing.
  • OneTimeTokenAuthenticationProvider already copies the details from the token into its result
    (authenticated.setDetails(otpAuthenticationToken.getDetails()),
    source),
    so it looks like the details were meant to be there.

Impact: anything that reads WebAuthenticationDetails gets null for one-time token logins — for example
AuthenticationSuccessEvent / InteractiveAuthenticationSuccessEvent listeners that record the caller's address.
With multi-factor authentication (form login, then one-time token) the authentication that ends up in the
SecurityContext is the one-time token result, so the address from the password step is not kept either.

To Reproduce

Self-contained program; needs only Spring Security 7.1.1 and spring-test (for the mock request) on the classpath:

import org.springframework.mock.web.MockFilterChain;
import org.springframework.mock.web.MockHttpServletRequest;
import org.springframework.mock.web.MockHttpServletResponse;
import org.springframework.security.authentication.ProviderManager;
import org.springframework.security.authentication.dao.DaoAuthenticationProvider;
import org.springframework.security.authentication.ott.GenerateOneTimeTokenRequest;
import org.springframework.security.authentication.ott.InMemoryOneTimeTokenService;
import org.springframework.security.authentication.ott.OneTimeTokenAuthenticationProvider;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.provisioning.InMemoryUserDetailsManager;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
import org.springframework.security.web.authentication.ott.OneTimeTokenAuthenticationFilter;

public class OttDetailsRepro {

	public static void main(String[] args) throws Exception {
		var users = new InMemoryUserDetailsManager(
				User.withUsername("user").password("{noop}password").roles("USER").build());

		// Form login: details are populated
		var formLogin = new UsernamePasswordAuthenticationFilter(
				new ProviderManager(new DaoAuthenticationProvider(users)));
		formLogin.setAuthenticationSuccessHandler((request, response, authentication) ->
				System.out.println("form login     -> details = " + authentication.getDetails()));
		var formRequest = post("/login");
		formRequest.setParameter("username", "user");
		formRequest.setParameter("password", "password");
		formLogin.doFilter(formRequest, new MockHttpServletResponse(), new MockFilterChain());

		// One-time token login: details are null, even with a details source set explicitly
		var tokens = new InMemoryOneTimeTokenService();
		var ottLogin = new OneTimeTokenAuthenticationFilter();
		ottLogin.setAuthenticationManager(new ProviderManager(new OneTimeTokenAuthenticationProvider(tokens, users)));
		ottLogin.setAuthenticationDetailsSource(new WebAuthenticationDetailsSource());
		ottLogin.setAuthenticationSuccessHandler((request, response, authentication) ->
				System.out.println("one-time token -> details = " + authentication.getDetails()));
		var ottRequest = post("/login/ott");
		ottRequest.setParameter("token", tokens.generate(new GenerateOneTimeTokenRequest("user")).getTokenValue());
		ottLogin.doFilter(ottRequest, new MockHttpServletResponse(), new MockFilterChain());
	}

	private static MockHttpServletRequest post(String path) {
		var request = new MockHttpServletRequest("POST", path);
		request.setRemoteAddr("203.0.113.7");
		return request;
	}

}

Output:

form login     -> details = WebAuthenticationDetails [RemoteIpAddress=203.0.113.7, SessionId=null]
one-time token -> details = null

Expected behavior

After a one-time token login, Authentication#getDetails() holds WebAuthenticationDetails, like the other login
mechanisms, and a configured authenticationDetailsSource is used.

A possible fix, matching BasicAuthenticationConverter
(source)
and BearerTokenAuthenticationConverter
(source):
give OneTimeTokenAuthenticationConverter an AuthenticationDetailsSource<HttpServletRequest, ?> (default
WebAuthenticationDetailsSource, with a setter) and set the details on the token it creates.

A broader alternative is for AbstractAuthenticationProcessingFilter#attemptAuthentication to apply its
authenticationDetailsSource to a converted authentication that has no details. That would also make the DSL setting
work, but it changes other converter-based filters too, so I leave that choice to you.

Workaround we use for now:

static AuthenticationConverter oneTimeTokenConverterWithDetails() {
	var converter = new OneTimeTokenAuthenticationConverter();
	var detailsSource = new WebAuthenticationDetailsSource();
	return (request) -> {
		Authentication authentication = converter.convert(request);
		if (authentication instanceof OneTimeTokenAuthenticationToken token) {
			token.setDetails(detailsSource.buildDetails(request));
		}
		return authentication;
	};
}

// http.oneTimeTokenLogin((ott) -> ott.authenticationConverter(oneTimeTokenConverterWithDetails()));

Sample

The program above is the complete reproduction. I can put it in a repository if that helps.

Related: #16856 noticed that otpAuthenticationToken has no details. The fix proposed there (setDetails(user)) would
put the user into the details; the user is already the principal, and details are meant to describe the request.

Environment: Spring Security 7.1.1 (Spring Boot 4.1.1), Java 25. The current main branch has the same code.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions