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

Jwt Principal customization #16231

Closed
vaa25 opened this issue Dec 7, 2024 · 10 comments · May be fixed by #16311
Closed

Jwt Principal customization #16231

vaa25 opened this issue Dec 7, 2024 · 10 comments · May be fixed by #16311
Assignees
Labels
in: oauth2 An issue in OAuth2 modules (oauth2-core, oauth2-client, oauth2-resource-server, oauth2-jose) type: enhancement A general enhancement

Comments

@vaa25
Copy link

vaa25 commented Dec 7, 2024

Expected Behavior
I want to use method authorization annotations and @AuthenticationPrincipal clean way with custom business fields using oauth2 authorization with spring-boot-starter-oauth2-resource-server supporting all spring.security.oauth2.resourceserver.jwt.* properties.

@PreAuthorize("principal.id==100")
@GetMapping
public MyPrincipal test(@AuthenticationPrincipal MyPrincipal principal) {
    return principal;
}

response:

{"id": 123, "name": "Alex"}

It can be achieved if Authentication object will have custom principal object:

public class JwtPrincipal implements MyPrincipal {
  private final Integer id;
  private final String name;
  public JwtPrincipal(Jwt jwt, String principalName) {
    this.id = Integer.parseInt(jwt.getClaim("id"));
    this.name=principalName;
  }
  @Override
  public Integer getId() {
    return id;
  }
  @Override
  public String getName() {
    return name;
  }
}

I'd like to add a principal converter as a bean or via OAuth2ResourceServerConfigurer for customization

public interface JwtPrincipalConverter {
    Object convert(Jwt jwt, String principalName);
}

Current Behavior

Current implementation sets org.springframework.security.oauth2.jwt.Jwt object as principal causing such implementation:

@PreAuthorize("principal.claims['id']=='100'")
@GetMapping
public Jwt test(@AuthenticationPrincipal Jwt principal) {
    return principal;
}

Here I have to write 'claims' in @PreAuthorize that is not business field, and receive Jwt principal as method parameter that is not business object.

Context
I believe having custom Principal object makes authorization control more readable and simple and can help to accept requests with different types of authorization into same controller method.

There is some workarounds, but they are not so simple as my proposition and may require third-party library.

  1. to use custom jwtAuthenticationConverter. In this case we have to write custom JwtAuthenticationToken and additional code to support spring.security.oauth2.resourceserver.jwt.* properties.
  2. to apply third-party library with its own properties and a little bit less additional code.

Based on stackoverflow question

P.S. I have implemented this feature locally, just waiting for your approve of this idea, guys.

@vaa25 vaa25 added status: waiting-for-triage An issue we've not yet triaged type: enhancement A general enhancement labels Dec 7, 2024
@jzheaux
Copy link
Contributor

jzheaux commented Dec 9, 2024

@vaa25, thanks for the suggestion. Before moving forward, I want to make sure we are on the same page.

It sounds like you have a custom principal so that you don't need to access the claims map directly. However, Jwt extends JwtClaimsAccessor which comes with a getId method.

Have you already tried using Jwt directly (instead of MyPrincipal) and doing @PreAuthorize("principal.id == '100'")? If that is not working for you, it may indicate a bug.

@jzheaux jzheaux added status: waiting-for-feedback We need additional information before we can continue and removed status: waiting-for-triage An issue we've not yet triaged labels Dec 9, 2024
@vaa25
Copy link
Author

vaa25 commented Dec 10, 2024

@jzheaux thanks for answer.

Using Jwt directly (instead of MyPrincipal) and doing @PreAuthorize("principal.id == '100'") can work if id was stored in "jti" claim.

But my point is not "id" itself. Claim can be "userId", or "accountId", or any other private Jwt claim, or part of claim or expression , e.g:
@PreAuthorize("principal.country == 'US'")
@PreAuthorize("principal.emailDomain == 'gmail.com'")

@Getter
public class JwtPrincipal implements MyPrincipal {
    private final Integer userId;
    private final Integer accountId;
    private final String country;
    private final String emailDomain;
    public JwtPrincipal(Jwt jwt) {
        this.userId = Integer.parseInt(jwt.getClaim("userId"));
        this.accountId = Integer.parseInt(jwt.getClaim("accountId"));
        this.country = jwt.getClaimAsString("country");
        this.emailDomain = jwt.getClaimAsString("email").split("@")[1];
    }
}

If some project has already implemented its own custom type of authorization with its own custom principal object (CustomPrincipal implements MyPrincipal) then ability to customize Jwt principal can help to migrate to jwt smoothly without any changes in controllers.

Main purpose is to add ability to break tight coupling of controllers and Jwt authorization easy and intuitive way.

@spring-projects-issues spring-projects-issues added status: feedback-provided Feedback has been provided and removed status: waiting-for-feedback We need additional information before we can continue labels Dec 10, 2024
@jzheaux
Copy link
Contributor

jzheaux commented Dec 11, 2024

Gotcha. It sounds like you have some custom claims and you'd like to be able to access them without going to the claims map. And it's certainly a reasonable thing to want to have a custom principal.

It also sounds like you are wanting to avoid creating a custom authentication object. I think a principal converter is a bit more fine-grained than we want to get to achieve something like that, but would something like the following work?

BearerTokenAuthentication is an authentication implementation that is intended for custom principals. For example, you can use a BearerTokenAuthentication instead of a JwtAuthenticationToken in the following way:

@Component
public JwtPrincipalAuthenticationConverter implements Converter<Jwt, BearerTokenAuthentication> {
    private final JwtAuthenticationConverter delegate = new JwtAuthenticationConverter();

    @Override 
    public BearerTokenAuthentication convert(Jwt jwt) {
        JwtAuthenticationToken token = this.delegate.convert(jwt);
        JwtPrincipal principal = new JwtPrincipal(jwt, token.getAuthorities());
        OAuth2AccessToken access = new OAuth2AccessToken(TokenType.BEARER, jwt.getTokenValue(),
               jwt.getIssuedAt(), jwt.getExpiresAt());
        return new BearerTokenAuthentication(principal, jwt, token.getAuthorities());
    }

    private static class JwtPrincipal implements MyPrincipal, OAuth2AuthenticatedPrincipal {
        // ...
    }
}

The primary change to your design would be to have JwtPrincipal to implement OAuth2AuthenticatedPrincipal. And depending on whether you need to reference the class elsewhere, you may even be able to keep the Spring Security interface hidden (as above).

Then @AuthenticationPrincipal will work as you described. How well would this work for you?

@vaa25
Copy link
Author

vaa25 commented Dec 11, 2024

To support spring.security.oauth2.resourceserver.jwt.* properties we have to add some modifications in constructor

@Component
public class JwtPrincipalAuthenticationConverter implements Converter<Jwt, BearerTokenAuthentication> {
    private final JwtAuthenticationConverter delegate = new JwtAuthenticationConverter();

    public JwtPrincipalAuthenticationConverter(OAuth2ResourceServerProperties properties) {
        final var jwt = properties.getJwt();
        if (StringUtils.hasText(jwt.getPrincipalClaimName())){
            delegate.setPrincipalClaimName(jwt.getPrincipalClaimName());
        }
        final var grantedAuthoritiesConverter = new JwtGrantedAuthoritiesConverter();
        if (StringUtils.hasText(jwt.getAuthoritiesClaimName())){
            grantedAuthoritiesConverter.setAuthoritiesClaimName(jwt.getAuthoritiesClaimName());
        }
        if (StringUtils.hasText(jwt.getAuthoritiesClaimDelimiter())){
            grantedAuthoritiesConverter.setAuthoritiesClaimDelimiter(jwt.getAuthoritiesClaimDelimiter());
        }
        if (jwt.getAuthorityPrefix() != null){
            grantedAuthoritiesConverter.setAuthorityPrefix(jwt.getAuthorityPrefix());
        }
        delegate.setJwtGrantedAuthoritiesConverter(grantedAuthoritiesConverter);
    }

    @Override
    public BearerTokenAuthentication convert(Jwt jwt) {
        JwtAuthenticationToken token = (JwtAuthenticationToken)this.delegate.convert(jwt);
        JwtPrincipal principal = new JwtPrincipal(jwt, token.getAuthorities());
        OAuth2AccessToken access = new OAuth2AccessToken(OAuth2AccessToken.TokenType.BEARER, jwt.getTokenValue(),
                jwt.getIssuedAt(), jwt.getExpiresAt());
        return new BearerTokenAuthentication(principal, access, token.getAuthorities());
    }

    private static class JwtPrincipal implements MyPrincipal, OAuth2AuthenticatedPrincipal {

        private final Jwt jwt;
        private final Collection<GrantedAuthority> grantedAuthorities;
        public JwtPrincipal(Jwt jwt, Collection<GrantedAuthority> authorities) {
            this.jwt = jwt;
            this.grantedAuthorities = authorities;
        }

        @Override
        public Integer getId() {
            return Integer.parseInt(jwt.getClaim("id"));
        }

        @JsonIgnore
        @Override
        public Map<String, Object> getAttributes() {
            return jwt.getClaims();
        }

        @JsonIgnore
        @Override
        public Collection<? extends GrantedAuthority> getAuthorities() {
            return grantedAuthorities;
        }

        @JsonIgnore
        @Override
        public String getName() {
            return jwt.getSubject();
        }
    }
}

To make it work we have to apply JwtPrincipalAuthenticationConverter via configurer, because it does't work as a bean. The JwtAuthenticationConverter can be picked up as a bean only.

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http, JwtPrincipalAuthenticationConverter jwtPrincipalAuthenticationConverter) throws Exception {
        return http
                .oauth2ResourceServer(x -> x.jwt(jwtConfigurer -> jwtConfigurer.jwtAuthenticationConverter(jwtPrincipalAuthenticationConverter)))
                .build();
    }

In general, this solution satisfies all my functional criteria, but implementation could be much simpler and intuitive ;) Just to compare:

@Component
public class MyJwtPrincipalConverter implements JwtPrincipalConverter {
    @Override
    public Object convert(Jwt jwt, String principalName) {
        return new JwtPrincipal(jwt);
    }

    private static class JwtPrincipal implements MyPrincipal {
        private final Jwt jwt;
        private JwtPrincipal(Jwt jwt) {
            this.jwt = jwt;
        }
        @Override
        public Integer getId() {
            return Integer.parseInt(jwt.getClaim("id"));
        }
    }
}

Thank you!

@jzheaux
Copy link
Contributor

jzheaux commented Dec 17, 2024

Thanks for the extra detail, @vaa25.

To make sure we are comparing apples to apples, let's see if I can help reduce some of that boilerplate first.

Because Spring Boot publishes a JwtAuthenticationConverter bean, you should be able to do:

@Component
public class JwtPrincipalAuthenticationConverter implements Converter<Jwt, JwtAuthenticationToken> {
    private final JwtAuthenticationConverter delegate;

    public JwtPrincipalAuthenticationConverter(JwtAuthenticationConverter delegate) {
        this.delegate = delegate;
    }

    @Override
    public BearerTokenAuthentication convert(Jwt jwt) {
        AbstractAuthenticationToken token = this.delegate.convert(jwt);
        JwtPrincipal principal = new JwtPrincipal(jwt);
        OAuth2AccessToken access = new OAuth2AccessToken(OAuth2AccessToken.TokenType.BEARER, jwt.getTokenValue(),
                jwt.getIssuedAt(), jwt.getExpiresAt());
        return new BearerTokenAuthentication(principal, access, token.getAuthorities());
    }

    private static class JwtPrincipal implements MyPrincipal, OAuth2AuthenticatedPrincipal {

        private final Jwt jwt;

        JwtPrincipal(Jwt jwt) {
            this.jwt = jwt;
        }

        @Override
        public Integer getId() {
            return Integer.parseInt(jwt.getClaim("id"));
        }

        @JsonIgnore
        @Override
        public Map<String, Object> getAttributes() {
            return jwt.getClaims();
        }

        @JsonIgnore
        @Override
        public Collection<? extends GrantedAuthority> getAuthorities() {
            return Collections.empty();
        }

        @JsonIgnore
        @Override
        public String getName() {
            return jwt.getSubject();
        }
    }
}

And then as you said:

@Bean
public SecurityFilterChain filterChain(HttpSecurity http, JwtPrincipalAuthenticationConverter jwtPrincipalAuthenticationConverter) throws Exception {
        return http
                .oauth2ResourceServer(x -> x.jwt(jwtConfigurer -> jwtConfigurer.jwtAuthenticationConverter(jwtPrincipalAuthenticationConverter)))
                .build();
}

Note that some of what you propose is also not quite realistic; for example, Security can't support a converter API that returns an Object as that would negate its ability to reason about the principal's properties while constructing an authentication token. Inevitably, you'll need to implement something, and OAuth2AuthenticatedPrincipal is quite lightweight.


Given that, I think we can get close to what you are proposing.

For example, we can enhance JwtBearerTokenAuthenticationConverter to have a setJwtGrantedAuthoritiesConverter and a setJwtAuthenticatedPrincipalConverter. Then you could do the following:

@Component
public class MyJwtPrincipalConverter implements Converter<Jwt, OAuth2AuthenticatedPrincipal> {
    @Override
    public OAuth2AuthenticatedPrincipal convert(Jwt jwt) {
        return new JwtPrincipal(jwt);
    }

    private static class JwtPrincipal implements MyPrincipal, OAuth2AuthenticatedPrincipal {
        private final Jwt jwt;
        private JwtPrincipal(Jwt jwt) {
            this.jwt = jwt;
        }
        @Override
        public Integer getId() {
            return Integer.parseInt(jwt.getClaim("id"));
        }

        // ...
    }
}

// ...

@Bean 
JwtBearerTokenAuthenticationConverter authenticationConverter(JwtAuthenticationConverter fromBoot,
        MyJwtPrincipalConverter principal) {
    Converter<Jwt, Collection<GrantedAuthority> authorities = (jwt) -> fromBoot.convert(jwt).getAuthorities();
    JwtBearerTokenAuthenticationConverter authentication = new JwtBearerTokenAuthenticationConverter();
    authentication.setJwtAuthenticatedPrincipalConverter(principal);
    authentication.setJwtGrantedAuthoritiesConverter(authorities);
    return authentication;
}

Is this a step in the right direction? If so, we can get this scheduled for the next minor release.

@jzheaux jzheaux added status: waiting-for-feedback We need additional information before we can continue and removed status: feedback-provided Feedback has been provided labels Dec 17, 2024
@jzheaux jzheaux self-assigned this Dec 17, 2024
vaa25 pushed a commit to vaa25/spring-security that referenced this issue Dec 19, 2024
@vaa25
Copy link
Author

vaa25 commented Dec 19, 2024

To inject JwtAuthenticationConverter is a great idea, thank you!

As for JwtBearerTokenAuthenticationConverter modification - developer still have to add some additional code:

public class Config {
	@Bean
	public SecurityFilterChain filterChain(HttpSecurity http, JwtBearerTokenAuthenticationConverter jwtPrincipalAuthenticationConverter) throws Exception {
		return http
				.oauth2ResourceServer(x -> x.jwt(jwtConfigurer -> jwtConfigurer.jwtAuthenticationConverter(jwtPrincipalAuthenticationConverter)))
				.build();
	}
	@Bean
	public JwtBearerTokenAuthenticationConverter authenticationConverter(JwtAuthenticationConverter fromBoot, MyJwtPrincipalConverter myJwtPrincipalConverter) {
		Converter<Jwt, Collection<GrantedAuthority>> authoritiesConverter = jwt -> fromBoot.convert(jwt).getAuthorities();
		JwtBearerTokenAuthenticationConverter authentication = new JwtBearerTokenAuthenticationConverter();
		authentication.setJwtAuthenticatedPrincipalConverter(myJwtPrincipalConverter);
		authentication.setJwtGrantedAuthoritiesConverter(authoritiesConverter);
		return authentication;
	}
}

@Component
public class MyJwtPrincipalConverter implements Converter<Jwt, OAuth2AuthenticatedPrincipal> {
	private final JwtAuthenticationConverter delegate;
	public MyJwtPrincipalConverter(JwtAuthenticationConverter delegate) {
		this.delegate = delegate;
	}
	@Override
        public OAuth2AuthenticatedPrincipal convert(Jwt jwt) {
		final var authenticationToken = delegate.convert(jwt);
		return new JwtPrincipal(jwt, authenticationToken.getName(), authenticationToken.getAuthorities());
        }
        private static class JwtPrincipal implements MyPrincipal, OAuth2AuthenticatedPrincipal {
                private final Jwt jwt;
		private final String name;
		private final Collection<? extends GrantedAuthority> authorities;
        
                private JwtPrincipal(Jwt jwt, String name, Collection<GrantedAuthority> authorities) {
                        this.jwt = jwt;
			this.name = name;
			this.authorities = authorities;
                }
		@Override
		public Integer getId() {
			return Integer.parseInt(jwt.getClaim("id"));
		}
		@JsonIgnore
		@Override
		public Map<String, Object> getAttributes() {
			return jwt.getClaims();
		}
		@JsonIgnore
		@Override
		public Collection<? extends GrantedAuthority> getAuthorities() {
			return authorities;
		}
		@JsonIgnore
		@Override
		public String getName() {
			return name;
		}
    }
}

My idea requires from developer just to implement JwtPrincipalConverter as a bean. See spring-security code change here

@spring-projects-issues spring-projects-issues added status: feedback-provided Feedback has been provided and removed status: waiting-for-feedback We need additional information before we can continue labels Dec 19, 2024
@jzheaux
Copy link
Contributor

jzheaux commented Dec 19, 2024

I believe what the developer would have to do is as I described; one top-level class and one @Bean. The DSL would be changed to pick up the JwtBearerTokenConverter instance automatically, so wiring in the filter chain would not be necessary.

JwtPrincipalConverter is effectively the same as Converter<Jwt, OAuth2AuthenticatedPrincipal> or did I miss something? The thing returned must be something that extends a Security type in order for Security code to be able to call it. Thus, the extra methods you noted would be there either way.

In sum, I don't see how to change Security's code to do what you proposed (e.g. the JwtPrincipalConverter that returns an Object.)

@vaa25
Copy link
Author

vaa25 commented Dec 19, 2024

JwtPrincipalConverter is not same as Converter<Jwt, OAuth2AuthenticatedPrincipal>

public interface JwtPrincipalConverter {

	Object convert(Jwt jwt, String principalName);

}

You can see my implementation of Security's code change here main...vaa25:spring-security:gh-16231-my-idea

@jzheaux
Copy link
Contributor

jzheaux commented Dec 19, 2024

I tried commenting on the code, but GH might not support that on commits. Want to submit a PR and we can discuss?

@jzheaux
Copy link
Contributor

jzheaux commented Dec 19, 2024

Thanks! I've left my comments there. Let's continue from that point. I'm going to close this ticket in favor of #16311

@jzheaux jzheaux closed this as completed Dec 19, 2024
@jzheaux jzheaux added in: oauth2 An issue in OAuth2 modules (oauth2-core, oauth2-client, oauth2-resource-server, oauth2-jose) and removed status: feedback-provided Feedback has been provided labels Dec 19, 2024
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
in: oauth2 An issue in OAuth2 modules (oauth2-core, oauth2-client, oauth2-resource-server, oauth2-jose) type: enhancement A general enhancement
Projects
None yet
Development

Successfully merging a pull request may close this issue.

3 participants