Skip to content

Commit

Permalink
gh-219 cast timestamp claims to Long instead of Integer in @WithJwt A…
Browse files Browse the repository at this point in the history
…uthentication factory
  • Loading branch information
ch4mpy committed Jun 29, 2024
1 parent e426ca2 commit 86a6b67
Showing 1 changed file with 152 additions and 137 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -50,8 +50,8 @@
import reactor.core.publisher.Mono;

/**
* Annotation to setup test {@link SecurityContext} with an {@link Authentication} instantiated by the (Reactive)JwtAuthenticaionConverter
* in the security conf. Usage on tests decorated with @AutoConfigureAddonsSecurity or @AutoConfigureAddonsWebSecurity::
* Annotation to setup test {@link SecurityContext} with an {@link Authentication} instantiated by the (Reactive)JwtAuthenticaionConverter in the security conf.
* Usage on tests decorated with @AutoConfigureAddonsSecurity or @AutoConfigureAddonsWebSecurity::
*
* <pre>
* &#64;Test
Expand All @@ -61,8 +61,8 @@
* }
* </pre>
*
* For usage with &#64;ParameterizedTest, you'll need a {@link MethodSource &#64;MethodSource} in a test running with
* &#64;TestInstance(Lifecycle.PER_CLASS). Authentication instance should be injected in the test with &#64;ParameterizedAuthentication.
* For usage with &#64;ParameterizedTest, you'll need a {@link MethodSource &#64;MethodSource} in a test running with &#64;TestInstance(Lifecycle.PER_CLASS).
* Authentication instance should be injected in the test with &#64;ParameterizedAuthentication.
*
* <pre>
* &#64;Autowired
Expand All @@ -79,8 +79,8 @@
* }
* </pre>
*
* If using spring-addons-oauth2-test without spring-addons-starter-oidc-test, you should explicitly import
* &#64;Import(AuthenticationFactoriesTestConf.class) (otherwise, the &#64;Addons...Test will pull this configuration for you)
* If using spring-addons-oauth2-test without spring-addons-starter-oidc-test, you should explicitly import &#64;Import(AuthenticationFactoriesTestConf.class)
* (otherwise, the &#64;Addons...Test will pull this configuration for you)
*
* @author Jérôme Wacongne &lt;ch4mp&#64;c4-soft.com&gt;
*/
Expand All @@ -90,135 +90,150 @@
@Documented
@WithSecurityContext(factory = WithJwt.AuthenticationFactory.class)
public @interface WithJwt {
@AliasFor("file")
String value() default "";

@AliasFor("value")
String file() default "";

String json() default "";

String bearerString() default AuthenticationFactory.DEFAULT_BEARER;

String headers() default AuthenticationFactory.DEFAULT_HEADERS;

@RequiredArgsConstructor
public static final class AuthenticationFactory implements WithSecurityContextFactory<WithJwt> {
static final String DEFAULT_BEARER = "test.jwt.bearer";
static final String DEFAULT_HEADERS = "{\"alg\": \"none\"}";

private final Optional<Converter<Jwt, ? extends AbstractAuthenticationToken>> jwtAuthenticationConverter;

private final Optional<Converter<Jwt, ? extends Mono<? extends AbstractAuthenticationToken>>> reactiveJwtAuthenticationConverter;

private final Converter<Jwt, AbstractAuthenticationToken> defaultAuthenticationConverter = new JwtAuthenticationConverter();

@Override
public SecurityContext createSecurityContext(WithJwt annotation) {
final var auth = authentication(annotation);

final var securityContext = SecurityContextHolder.createEmptyContext();
securityContext.setAuthentication(auth);

return securityContext;
}

/**
* @param annotation Test annotation with reference to a classpath resource or a JSON string to get claims from (and optional JWT headers
* and Bearer string)
* @return an {@link Authentication} instance built by the JWT authentication converter in security configuration
*/
public AbstractAuthenticationToken authentication(WithJwt annotation) {
final var headers = parseJson(annotation.headers());

final var claims = new HashMap<String, Object>();
if (StringUtils.hasText(annotation.value())) {
claims.putAll(parseFile(annotation.value()));
}
if (StringUtils.hasText(annotation.file())) {
claims.putAll(parseFile(annotation.file()));
}
if (StringUtils.hasText(annotation.json())) {
claims.putAll(parseJson(annotation.json()));
}

return authentication(claims, headers, annotation.bearerString());
}

/**
* @param claims the test JWT claims
* @param headers the test JWT headers
* @param bearerString the test JWT Bearer String
* @return an {@link Authentication} instance built by the JWT authentication converter in security configuration
*/
@SuppressWarnings("null")
public AbstractAuthenticationToken authentication(Map<String, Object> claims, Map<String, Object> headers, String bearerString) {
final var now = Instant.now();
final var iat = Optional.ofNullable((Integer) claims.get(JWTClaimNames.ISSUED_AT)).map(Instant::ofEpochSecond).orElse(now);
final var exp = Optional.ofNullable((Integer) claims.get(JWTClaimNames.EXPIRATION_TIME)).map(Instant::ofEpochSecond).orElse(now.plusSeconds(42));

final var jwt = new Jwt(bearerString, iat, exp, headers, claims);

return jwtAuthenticationConverter.map(c -> {
final AbstractAuthenticationToken auth = c.convert(jwt);
return auth;
}).orElseGet(() -> reactiveJwtAuthenticationConverter.map(c -> {
final AbstractAuthenticationToken auth = c.convert(jwt).block();
return auth;
}).orElse(defaultAuthenticationConverter.convert(jwt)));
}

/**
* Build an {@link Authentication} for each of the claim-sets provided as classpath resources (JSON file)
*
* @param classpathResources classpath resources to get JWT claims from
* @return an stream of {@link Authentication} instances built by the JWT authentication converter in security
* configuration (using default JWT headers and Bearer String)
*/
public Stream<AbstractAuthenticationToken> authenticationsFrom(String... classpathResources) {
return Stream.of(classpathResources).map(AuthenticationFactory::parseFile)
.map(claims -> this.authentication(claims, parseJson(DEFAULT_HEADERS), DEFAULT_BEARER));
}

/**
* Extracts the claim-set in a JSON file
*
* @param fileName
* @return
*/
public static Map<String, Object> parseFile(String fileName) {
if (!StringUtils.hasText(fileName)) {
return Map.of();
}

InputStream cpRessource;
try {
cpRessource = new ClassPathResource(fileName).getInputStream();
} catch (IOException e) {
throw new RuntimeException("Failed to load classpath resource %s".formatted(fileName), e);
}
try {
return new JSONParser(JSONParser.MODE_PERMISSIVE).parse(cpRessource, JSONObject.class);
} catch (final ParseException | UnsupportedEncodingException e) {
throw new RuntimeException("Invalid JWT payload in classpath resource %s".formatted(fileName));
}
}

/**
* Extracts the claim-set in a JSON String
*
* @param json
* @return
*/
public static Map<String, Object> parseJson(String json) {
if (!StringUtils.hasText(json)) {
return Map.of();
}
try {
return new JSONParser(JSONParser.MODE_PERMISSIVE).parse(json, JSONObject.class);
} catch (final ParseException e) {
throw new RuntimeException("Invalid JSON payload in @WithJwt");
}
}
}
@AliasFor("file")
String value() default "";

@AliasFor("value")
String file() default "";

String json() default "";

String bearerString() default AuthenticationFactory.DEFAULT_BEARER;

String headers() default AuthenticationFactory.DEFAULT_HEADERS;

@RequiredArgsConstructor
public static final class AuthenticationFactory implements WithSecurityContextFactory<WithJwt> {
static final String DEFAULT_BEARER = "test.jwt.bearer";
static final String DEFAULT_HEADERS = "{\"alg\": \"none\"}";

private final Optional<Converter<Jwt, ? extends AbstractAuthenticationToken>> jwtAuthenticationConverter;

private final Optional<Converter<Jwt, ? extends Mono<? extends AbstractAuthenticationToken>>> reactiveJwtAuthenticationConverter;

private final Converter<Jwt, AbstractAuthenticationToken> defaultAuthenticationConverter = new JwtAuthenticationConverter();

@Override
public SecurityContext createSecurityContext(WithJwt annotation) {
final var auth = authentication(annotation);

final var securityContext = SecurityContextHolder.createEmptyContext();
securityContext.setAuthentication(auth);

return securityContext;
}

/**
* @param annotation Test annotation with reference to a classpath resource or a JSON string to get claims from (and optional JWT headers and Bearer
* string)
* @return an {@link Authentication} instance built by the JWT authentication converter in security configuration
*/
public AbstractAuthenticationToken authentication(WithJwt annotation) {
final var headers = parseJson(annotation.headers());

final var claims = new HashMap<String, Object>();
if (StringUtils.hasText(annotation.value())) {
claims.putAll(parseFile(annotation.value()));
}
if (StringUtils.hasText(annotation.file())) {
claims.putAll(parseFile(annotation.file()));
}
if (StringUtils.hasText(annotation.json())) {
claims.putAll(parseJson(annotation.json()));
}

return authentication(claims, headers, annotation.bearerString());
}

/**
* @param claims the test JWT claims
* @param headers the test JWT headers
* @param bearerString the test JWT Bearer String
* @return an {@link Authentication} instance built by the JWT authentication converter in security configuration
*/
@SuppressWarnings("null")
public AbstractAuthenticationToken authentication(Map<String, Object> claims, Map<String, Object> headers, String bearerString) {
final var now = Instant.now();
final var iat = Optional.ofNullable(toLong(claims.get(JWTClaimNames.ISSUED_AT))).map(Instant::ofEpochSecond).orElse(now);
final var exp = Optional.ofNullable(toLong(claims.get(JWTClaimNames.EXPIRATION_TIME))).map(Instant::ofEpochSecond).orElse(now.plusSeconds(42));

final var jwt = new Jwt(bearerString, iat, exp, headers, claims);

return jwtAuthenticationConverter.map(c -> {
final AbstractAuthenticationToken auth = c.convert(jwt);
return auth;
}).orElseGet(() -> reactiveJwtAuthenticationConverter.map(c -> {
final AbstractAuthenticationToken auth = c.convert(jwt).block();
return auth;
}).orElse(defaultAuthenticationConverter.convert(jwt)));
}

private Long toLong(Object claim) {
if (claim == null) {
return null;
}
if (claim instanceof Long l) {
return l;
}
if (claim instanceof Integer i) {
return i.longValue();
}
return null;
}

/**
* Build an {@link Authentication} for each of the claim-sets provided as classpath resources (JSON file)
*
* @param classpathResources classpath resources to get JWT claims from
* @return an stream of {@link Authentication} instances built by the JWT authentication converter in security configuration (using default JWT headers
* and Bearer String)
*/
public Stream<AbstractAuthenticationToken> authenticationsFrom(String... classpathResources) {
return Stream
.of(classpathResources)
.map(AuthenticationFactory::parseFile)
.map(claims -> this.authentication(claims, parseJson(DEFAULT_HEADERS), DEFAULT_BEARER));
}

/**
* Extracts the claim-set in a JSON file
*
* @param fileName
* @return
*/
public static Map<String, Object> parseFile(String fileName) {
if (!StringUtils.hasText(fileName)) {
return Map.of();
}

InputStream cpRessource;
try {
cpRessource = new ClassPathResource(fileName).getInputStream();
} catch (IOException e) {
throw new RuntimeException("Failed to load classpath resource %s".formatted(fileName), e);
}
try {
return new JSONParser(JSONParser.MODE_PERMISSIVE).parse(cpRessource, JSONObject.class);
} catch (final ParseException | UnsupportedEncodingException e) {
throw new RuntimeException("Invalid JWT payload in classpath resource %s".formatted(fileName));
}
}

/**
* Extracts the claim-set in a JSON String
*
* @param json
* @return
*/
public static Map<String, Object> parseJson(String json) {
if (!StringUtils.hasText(json)) {
return Map.of();
}
try {
return new JSONParser(JSONParser.MODE_PERMISSIVE).parse(json, JSONObject.class);
} catch (final ParseException e) {
throw new RuntimeException("Invalid JSON payload in @WithJwt");
}
}
}
}

0 comments on commit 86a6b67

Please sign in to comment.