Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

gh-16231 add JwtPrincipalConverter.java support #16311

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationConverter;
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationProvider;
import org.springframework.security.oauth2.server.resource.authentication.OpaqueTokenAuthenticationProvider;
import org.springframework.security.oauth2.server.resource.introspection.JwtPrincipalConverter;
import org.springframework.security.oauth2.server.resource.introspection.OpaqueTokenAuthenticationConverter;
import org.springframework.security.oauth2.server.resource.introspection.OpaqueTokenIntrospector;
import org.springframework.security.oauth2.server.resource.introspection.SpringOpaqueTokenIntrospector;
Expand Down Expand Up @@ -103,6 +104,8 @@
* <li>customizing the conversion from a {@link Jwt} to an
* {@link org.springframework.security.core.Authentication} with
* {@link JwtConfigurer#jwtAuthenticationConverter(Converter)}</li>
* <li>customizing the conversion from a {@link Jwt} to a principal {@link Object} with
* {@link JwtConfigurer#jwtPrincipalConverter(JwtPrincipalConverter)}</li>
* </ul>
*
* <p>
Expand Down Expand Up @@ -382,6 +385,8 @@ public class JwtConfigurer {

private Converter<Jwt, ? extends AbstractAuthenticationToken> jwtAuthenticationConverter;

private JwtPrincipalConverter jwtPrincipalConverter;

JwtConfigurer(ApplicationContext context) {
this.context = context;
}
Expand All @@ -408,6 +413,11 @@ public JwtConfigurer jwtAuthenticationConverter(
return this;
}

public JwtConfigurer jwtPrincipalConverter(JwtPrincipalConverter jwtPrincipalConverter) {
this.jwtPrincipalConverter = jwtPrincipalConverter;
return this;
}

/**
* @deprecated For removal in 7.0. Use {@link #jwt(Customizer)} or
* {@code jwt(Customizer.withDefaults())} to stick with defaults. See the <a href=
Expand All @@ -421,16 +431,33 @@ public OAuth2ResourceServerConfigurer<H> and() {

Converter<Jwt, ? extends AbstractAuthenticationToken> getJwtAuthenticationConverter() {
if (this.jwtAuthenticationConverter == null) {
if (this.context.getBeanNamesForType(JwtAuthenticationConverter.class).length > 0) {
this.jwtAuthenticationConverter = this.context.getBean(JwtAuthenticationConverter.class);
}
else {
this.jwtAuthenticationConverter = new JwtAuthenticationConverter();
}
final var authenticationConverter = getOrCreateJwtAuthenticationConverter();
authenticationConverter.setJwtPrincipalConverter(getJwtPrincipalConverter());
this.jwtAuthenticationConverter = authenticationConverter;
}
return this.jwtAuthenticationConverter;
}

JwtPrincipalConverter getJwtPrincipalConverter() {
if (this.jwtPrincipalConverter == null) {
if (this.context.getBeanNamesForType(JwtPrincipalConverter.class).length > 0) {
return this.context.getBean(JwtPrincipalConverter.class);
} else {
return (jwt, principalName) -> jwt;
}
} else {
return this.jwtPrincipalConverter;
}
}

private JwtAuthenticationConverter getOrCreateJwtAuthenticationConverter() {
if (this.context.getBeanNamesForType(JwtAuthenticationConverter.class).length > 0) {
return this.context.getBean(JwtAuthenticationConverter.class);
} else {
return new JwtAuthenticationConverter();
}
}

JwtDecoder getJwtDecoder() {
if (this.decoder == null) {
return this.context.getBean(JwtDecoder.class);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,7 @@
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationConverter;
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken;
import org.springframework.security.oauth2.server.resource.authentication.JwtIssuerAuthenticationManagerResolver;
import org.springframework.security.oauth2.server.resource.introspection.JwtPrincipalConverter;
import org.springframework.security.oauth2.server.resource.introspection.NimbusOpaqueTokenIntrospector;
import org.springframework.security.oauth2.server.resource.introspection.OpaqueTokenAuthenticationConverter;
import org.springframework.security.oauth2.server.resource.introspection.OpaqueTokenIntrospector;
Expand Down Expand Up @@ -1398,6 +1399,60 @@ public void getJwtAuthenticationConverterWhenDuplicateConverterBeansThenThrowsEx
.isThrownBy(jwtConfigurer::getJwtAuthenticationConverter);
}

@Test
public void getJwtPrincipalConverterWhenNoConverterSpecifiedThenTheDefaultIsUsed() {
ApplicationContext context = this.spring.context(new GenericWebApplicationContext()).getContext();
OAuth2ResourceServerConfigurer.JwtConfigurer jwtConfigurer = new OAuth2ResourceServerConfigurer(context).jwt();
assertThat(jwtConfigurer.getJwtPrincipalConverter()).isInstanceOf(JwtPrincipalConverter.class);
}

@Test
public void getJwtPrincipalConverterWhenConverterBeanSpecified() {
JwtPrincipalConverter converterBean = mock(JwtPrincipalConverter.class);
GenericWebApplicationContext context = new GenericWebApplicationContext();
context.registerBean(JwtPrincipalConverter.class, () -> converterBean);
this.spring.context(context).autowire();
OAuth2ResourceServerConfigurer.JwtConfigurer jwtConfigurer = new OAuth2ResourceServerConfigurer(context).jwt();
assertThat(jwtConfigurer.getJwtPrincipalConverter()).isEqualTo(converterBean);
}

@Test
public void getJwtPrincipalConverterWhenConverterBeanAndAnotherOnTheDslThenTheDslOneIsUsed() {
JwtPrincipalConverter converter = mock(JwtPrincipalConverter.class);
JwtPrincipalConverter converterBean = mock(JwtPrincipalConverter.class);
GenericWebApplicationContext context = new GenericWebApplicationContext();
context.registerBean(JwtPrincipalConverter.class, () -> converterBean);
this.spring.context(context).autowire();
OAuth2ResourceServerConfigurer.JwtConfigurer jwtConfigurer = new OAuth2ResourceServerConfigurer(context).jwt();
jwtConfigurer.jwtPrincipalConverter(converter);
assertThat(jwtConfigurer.getJwtPrincipalConverter()).isEqualTo(converter);
}

@Test
public void getJwtPrincipalConverterWhenDuplicateConverterBeansAndAnotherOnTheDslThenTheDslOneIsUsed() {
JwtPrincipalConverter converter = mock(JwtPrincipalConverter.class);
JwtPrincipalConverter converterBean = mock(JwtPrincipalConverter.class);
GenericWebApplicationContext context = new GenericWebApplicationContext();
context.registerBean("converterOne", JwtPrincipalConverter.class, () -> converterBean);
context.registerBean("converterTwo", JwtPrincipalConverter.class, () -> converterBean);
this.spring.context(context).autowire();
OAuth2ResourceServerConfigurer.JwtConfigurer jwtConfigurer = new OAuth2ResourceServerConfigurer(context).jwt();
jwtConfigurer.jwtPrincipalConverter(converter);
assertThat(jwtConfigurer.getJwtPrincipalConverter()).isEqualTo(converter);
}

@Test
public void getJwtPrincipalConverterWhenDuplicateConverterBeansThenThrowsException() {
JwtPrincipalConverter converterBean = mock(JwtPrincipalConverter.class);
GenericWebApplicationContext context = new GenericWebApplicationContext();
context.registerBean("converterOne", JwtPrincipalConverter.class, () -> converterBean);
context.registerBean("converterTwo", JwtPrincipalConverter.class, () -> converterBean);
this.spring.context(context).autowire();
OAuth2ResourceServerConfigurer.JwtConfigurer jwtConfigurer = new OAuth2ResourceServerConfigurer(context).jwt();
assertThatExceptionOfType(NoUniqueBeanDefinitionException.class)
.isThrownBy(jwtConfigurer::getJwtPrincipalConverter);
}

@Test
public void getWhenCustomAuthenticationConverterThenUsed() throws Exception {
this.spring
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/*
* Copyright 2002-2022 the original author or authors.
* Copyright 2002-2024 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
Expand All @@ -23,6 +23,7 @@
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.security.oauth2.jwt.JwtClaimNames;
import org.springframework.security.oauth2.server.resource.introspection.JwtPrincipalConverter;
import org.springframework.util.Assert;

/**
Expand All @@ -35,15 +36,15 @@
public class JwtAuthenticationConverter implements Converter<Jwt, AbstractAuthenticationToken> {

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

private JwtPrincipalConverter principalConverter = (jwt, principalClaimValue) -> jwt;
private String principalClaimName = JwtClaimNames.SUB;

@Override
public final AbstractAuthenticationToken convert(Jwt jwt) {
Collection<GrantedAuthority> authorities = this.jwtGrantedAuthoritiesConverter.convert(jwt);

String principalClaimValue = jwt.getClaimAsString(this.principalClaimName);
return new JwtAuthenticationToken(jwt, authorities, principalClaimValue);
Object principal = principalConverter.convert(jwt, principalClaimValue);
return new JwtAuthenticationToken(jwt, principal, authorities, principalClaimValue);
}

/**
Expand All @@ -69,4 +70,13 @@ public void setPrincipalClaimName(String principalClaimName) {
this.principalClaimName = principalClaimName;
}

/**
* Sets the principal converter. Defaults to {@link Jwt}.
* @param jwtPrincipalConverter The principal converter
*/
public void setJwtPrincipalConverter(JwtPrincipalConverter jwtPrincipalConverter) {
Assert.notNull(jwtPrincipalConverter, "jwtPrincipalConverter cannot be null");
this.principalConverter = jwtPrincipalConverter;
}

}
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/*
* Copyright 2002-2018 the original author or authors.
* Copyright 2002-2024 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
Expand Down Expand Up @@ -72,6 +72,19 @@ public JwtAuthenticationToken(Jwt jwt, Collection<? extends GrantedAuthority> au
this.name = name;
}

/**
* Constructs a {@code JwtAuthenticationToken} using the provided parameters.
* @param jwt the JWT
* @param principal the principal converted from JWT
* @param authorities the authorities assigned to the JWT
* @param name the principal name
*/
public JwtAuthenticationToken(Jwt jwt, Object principal, Collection<? extends GrantedAuthority> authorities, String name) {
super(jwt, principal, jwt, authorities);
this.setAuthenticated(true);
this.name = name;
}

@Override
public Map<String, Object> getTokenAttributes() {
return this.getToken().getClaims();
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/*
* Copyright 2002-2023 the original author or authors.
* Copyright 2002-2024 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
Expand All @@ -16,6 +16,7 @@

package org.springframework.security.oauth2.server.resource.authentication;

import org.springframework.security.oauth2.server.resource.introspection.JwtPrincipalConverter;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;

Expand All @@ -38,7 +39,7 @@ public final class ReactiveJwtAuthenticationConverter implements Converter<Jwt,

private Converter<Jwt, Flux<GrantedAuthority>> jwtGrantedAuthoritiesConverter = new ReactiveJwtGrantedAuthoritiesConverterAdapter(
new JwtGrantedAuthoritiesConverter());

private JwtPrincipalConverter principalConverter = (jwt, principalName) -> jwt;
private String principalClaimName = JwtClaimNames.SUB;

@Override
Expand All @@ -48,7 +49,8 @@ public Mono<AbstractAuthenticationToken> convert(Jwt jwt) {
.collectList()
.map((authorities) -> {
String principalName = jwt.getClaimAsString(this.principalClaimName);
return new JwtAuthenticationToken(jwt, authorities, principalName);
Object principal = principalConverter.convert(jwt, principalName);
return new JwtAuthenticationToken(jwt, principal, authorities, principalName);
});
// @formatter:on
}
Expand All @@ -75,4 +77,13 @@ public void setPrincipalClaimName(String principalClaimName) {
this.principalClaimName = principalClaimName;
}

/**
* Sets the principal converter. Defaults to {@link Jwt}.
* @param jwtPrincipalConverter The principal converter
*/
public void setJwtPrincipalConverter(JwtPrincipalConverter jwtPrincipalConverter) {
Assert.notNull(jwtPrincipalConverter, "jwtPrincipalConverter cannot be null");
this.principalConverter = jwtPrincipalConverter;
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
/*
* Copyright 2002-2024 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package org.springframework.security.oauth2.server.resource.introspection;

import org.springframework.security.oauth2.jwt.Jwt;

/**
* @author Alex Vlasov
*/
public interface JwtPrincipalConverter {

Object convert(Jwt jwt, String principalName);
Copy link
Contributor

@jzheaux jzheaux Dec 19, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just connecting the two conversations here. This should return a Spring Security interface. That allows Spring to evolve around the API over time and reason about its contents. If it returns Object, we lose that ability.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is, for example, why UserDetailsService returns a UserDetails and not Object. This is similar for OpaqueTokenIntrospector and other principal-deriving interfaces in Spring Security.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For additional context, please see the code in this sample application.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Authentication.principal is an Object. Any other type would restrict principal type. If Object is not allowed by SS code convention, then could it be some marker interface? That marker interface could be a superinterface for other principal interfaces like AuthenticatedPrincipal. Or maybe it could be AuthenticatedPrincipal itself. Sorry, I am not very familiar with SS architecture and not figure out what to do in this situation.

Copy link
Contributor

@jzheaux jzheaux Dec 19, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No problem. What I'd recommend is that it return OAuth2AuthenticatedPrincipal. This allows a converter to transmit any user-level authorities (which can be different from the authorities derived from the JWT) along with the custom user.

If implementing extra methods is a concern, I think we can look at adding default implementations to some of those methods.

This doesn't restrict the type like you might think; since it is an interface, any custom principal can implement it with a few lines.


}
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/*
* Copyright 2002-2018 the original author or authors.
* Copyright 2002-2024 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
Expand All @@ -25,6 +25,7 @@
import org.springframework.security.authentication.AbstractAuthenticationToken;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.security.oauth2.jwt.TestJwts;

Expand Down Expand Up @@ -112,4 +113,19 @@ public void convertWhenPrincipalClaimNameSetAndClaimValueIsNotString() {
assertThat(authentication.getName()).isEqualTo("100");
}

@Test
public void convertWithCustomJwtPrincipalConverter() {
this.jwtAuthenticationConverter.setJwtPrincipalConverter((jwt, name) -> User.withUsername(name).password("").build());
Jwt jwt = TestJwts.user();
AbstractAuthenticationToken authentication = this.jwtAuthenticationConverter.convert(jwt);
assertThat(authentication.getPrincipal()).isInstanceOf(User.class).hasFieldOrPropertyWithValue("username", "mock-test-subject");
}

@Test
public void convertWithDefaultJwtPrincipalConverter() {
Jwt jwt = TestJwts.user();
AbstractAuthenticationToken authentication = this.jwtAuthenticationConverter.convert(jwt);
assertThat(authentication.getPrincipal()).isInstanceOf(Jwt.class);
}

}
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/*
* Copyright 2002-2018 the original author or authors.
* Copyright 2002-2024 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
Expand All @@ -24,11 +24,14 @@

import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.AuthorityUtils;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.oauth2.jose.jws.JwsAlgorithms;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.security.oauth2.jwt.TestJwts;

import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException;
import static org.mockito.Mockito.mock;

/**
* Tests for {@link JwtAuthenticationToken}
Expand Down Expand Up @@ -115,6 +118,20 @@ public void getNameWhenConstructedWithNoSubjectThenReturnsNull() {
assertThat(new JwtAuthenticationToken(jwt).getName()).isNull();
}

@Test
public void testConstructorWithPrincipal() {
Collection<GrantedAuthority> authorities = AuthorityUtils.createAuthorityList("test");
User principal = mock(User.class);
Jwt jwt = TestJwts.user();
JwtAuthenticationToken token = new JwtAuthenticationToken(jwt, principal, authorities, "Hayden");
assertThat(token.getToken()).isSameAs(jwt);
assertThat(token.getCredentials()).isSameAs(jwt);
assertThat(token.getPrincipal()).isSameAs(principal);
assertThat(token.getAuthorities()).isEqualTo(authorities);
assertThat(token.isAuthenticated()).isTrue();
assertThat(token.getName()).isEqualTo("Hayden");
}

private Jwt.Builder builder() {
return Jwt.withTokenValue("token").header("alg", JwsAlgorithms.RS256);
}
Expand Down
Loading