Skip to content

Commit

Permalink
Add spring jwt user provider
Browse files Browse the repository at this point in the history
Add spring jwt user provider test
  • Loading branch information
felixklauke committed Jul 12, 2022
1 parent c26be0b commit 6e8de78
Show file tree
Hide file tree
Showing 6 changed files with 209 additions and 3 deletions.
1 change: 1 addition & 0 deletions buildSrc/src/main/java/Config.kt
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ object Config {
val springBootStarterAop = "org.springframework.boot:spring-boot-starter-aop:$springBootVersion"
val springBootStarterSecurity = "org.springframework.boot:spring-boot-starter-security:$springBootVersion"
val springBootStarterJdbc = "org.springframework.boot:spring-boot-starter-jdbc:$springBootVersion"
val springBootStartOauth2ResourceServer = "org.springframework.boot:spring-boot-starter-oauth2-resource-server:$springBootVersion"

val springWeb = "org.springframework:spring-webmvc"
val springWebflux = "org.springframework:spring-webflux"
Expand Down
2 changes: 2 additions & 0 deletions sentry-spring-boot-starter/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ dependencies {
compileOnly(Config.Libs.servletApi)
compileOnly(Config.Libs.springBootStarterAop)
compileOnly(Config.Libs.springBootStarterSecurity)
compileOnly(Config.Libs.springBootStartOauth2ResourceServer)
compileOnly(Config.Libs.reactorCore)

annotationProcessor(Config.AnnotationProcessors.springBootAutoConfigure)
Expand All @@ -66,6 +67,7 @@ dependencies {
testImplementation(Config.Libs.springBootStarterWeb)
testImplementation(Config.Libs.springBootStarterWebflux)
testImplementation(Config.Libs.springBootStarterSecurity)
testImplementation(Config.Libs.springBootStartOauth2ResourceServer)
testImplementation(Config.Libs.springBootStarterAop)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@
import org.springframework.core.Ordered;
import org.springframework.core.annotation.Order;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.web.client.RestTemplate;
import org.springframework.web.reactive.function.client.WebClient;
import org.springframework.web.servlet.HandlerExceptionResolver;
Expand Down Expand Up @@ -166,6 +167,27 @@ static class SentrySecurityConfiguration {
}
}

@Configuration(proxyBeanMethods = false)
@ConditionalOnClass(Jwt.class)
@Open
static class SentryJwtConfiguration {

/**
* Configures {@link SentrySpringJwtUserProvider} only if Spring Jwt is on the
* classpath. Its order is set to be higher than {@link
* SentrySecurityConfiguration#springSecuritySentryUserProvider(SentryOptions)}
*
* @param sentryOptions the Sentry options
* @return {@link SentrySpringJwtUserProvider}
*/
@Bean
@Order(2)
public @NotNull SentrySpringJwtUserProvider springJwtUserProvider(
final @NotNull SentryOptions sentryOptions) {
return new SentrySpringJwtUserProvider(sentryOptions);
}
}

/**
* Configures {@link SentryUserFilter}. By default it runs as the last filter in order to make
* sure that all potential authentication information is propagated to {@link
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
package io.sentry.spring.boot;

import io.sentry.SentryOptions;
import io.sentry.protocol.User;
import io.sentry.spring.SentryUserProvider;
import io.sentry.util.Objects;
import java.util.HashMap;
import java.util.Map;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.oauth2.jwt.Jwt;

public final class SentrySpringJwtUserProvider implements SentryUserProvider {
private final @NotNull SentryOptions options;

public SentrySpringJwtUserProvider(final @NotNull SentryOptions options) {
this.options = Objects.requireNonNull(options, "options is required");
}

@Override
public @Nullable User provideUser() {
if (options.isSendDefaultPii()) {
final Object principal = SecurityContextHolder.getContext().getAuthentication().getPrincipal();
if (principal instanceof Jwt) {
return extraJwtUser((Jwt) principal);
} else {
return null;
}
}
return null;
}

private User extraJwtUser(Jwt jwt) {
final User user = new User();
user.setId(jwt.getSubject());
user.setEmail(jwt.getClaimAsString("email"));
user.setUsername(findJwtUsername(jwt));
user.setOthers(findJwtInfo(jwt));
return user;
}

private @Nullable String findJwtUsername(Jwt jwt) {
if (jwt.hasClaim("username")) {
return jwt.getClaimAsString("username");
} else if (jwt.hasClaim("preferred_username")) {
return jwt.getClaimAsString("preferred_username");
} else if (jwt.hasClaim("name")) {
return jwt.getClaimAsString("name");
} else if (jwt.hasClaim("email")) {
return jwt.getClaimAsString("email");
} else {
return null;
}
}

private Map<String, String> findJwtInfo(Jwt jwt) {
final Map<String, String> info = new HashMap<>();
if (jwt.hasClaim("iss")) {
info.put("iss", jwt.getClaimAsString("iss"));
}
if (jwt.hasClaim("aud")) {
info.put("aud", jwt.getClaimAsString("aud"));
}
if (jwt.hasClaim("exp")) {
info.put("exp", jwt.getClaimAsString("exp"));
}
if (jwt.hasClaim("iat")) {
info.put("iat", jwt.getClaimAsString("iat"));
}
if (jwt.hasClaim("scope")) {
info.put("scope", jwt.getClaimAsString("scope"));
}
return info;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ import org.springframework.context.annotation.Configuration
import org.springframework.core.Ordered
import org.springframework.core.annotation.Order
import org.springframework.security.core.context.SecurityContextHolder
import org.springframework.security.oauth2.jwt.Jwt
import org.springframework.web.client.RestTemplate
import org.springframework.web.reactive.function.client.WebClient
import org.springframework.web.servlet.HandlerExceptionResolver
Expand Down Expand Up @@ -328,10 +329,11 @@ class SentryAutoConfigurationTest {
.withConfiguration(UserConfigurations.of(SentryUserProviderConfiguration::class.java))
.run {
val userProviders = it.getSentryUserProviders()
assertEquals(3, userProviders.size)
assertEquals(4, userProviders.size)
assertTrue(userProviders[0] is HttpServletRequestSentryUserProvider)
assertTrue(userProviders[1] is SpringSecuritySentryUserProvider)
assertTrue(userProviders[2] is CustomSentryUserProvider)
assertTrue(userProviders[2] is SentrySpringJwtUserProvider)
assertTrue(userProviders[3] is CustomSentryUserProvider)
}
}

Expand All @@ -341,10 +343,11 @@ class SentryAutoConfigurationTest {
.withConfiguration(UserConfigurations.of(SentryHighestOrderUserProviderConfiguration::class.java))
.run {
val userProviders = it.getSentryUserProviders()
assertEquals(3, userProviders.size)
assertEquals(4, userProviders.size)
assertTrue(userProviders[0] is CustomSentryUserProvider)
assertTrue(userProviders[1] is HttpServletRequestSentryUserProvider)
assertTrue(userProviders[2] is SpringSecuritySentryUserProvider)
assertTrue(userProviders[3] is SentrySpringJwtUserProvider)
}
}

Expand All @@ -361,6 +364,19 @@ class SentryAutoConfigurationTest {
}
}

@Test
fun `when Jwt is not on the classpath, SentrySpringJwtUserProvider is not configured`() {
contextRunner.withPropertyValues("sentry.dsn=http://key@localhost/proj", "sentry.send-default-pii=true")
.withClassLoader(FilteredClassLoader(Jwt::class.java))
.run { ctx ->
val userProviders = ctx.getSentryUserProviders()
assertTrue(userProviders.isNotEmpty())
userProviders.forEach {
assertFalse(it is SentrySpringJwtUserProvider)
}
}
}

@Test
fun `when Spring MVC is not on the classpath, SentryExceptionResolver is not configured`() {
contextRunner.withPropertyValues("sentry.dsn=http://key@localhost/proj", "sentry.send-default-pii=true")
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
package io.sentry.spring.boot

import com.nhaarman.mockitokotlin2.mock
import com.nhaarman.mockitokotlin2.whenever
import io.sentry.SentryOptions
import org.junit.jupiter.api.Test
import org.springframework.security.core.Authentication
import org.springframework.security.core.context.SecurityContext
import org.springframework.security.core.context.SecurityContextHolder
import org.springframework.security.oauth2.jwt.Jwt
import java.time.Instant
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
import kotlin.test.assertNull

class SentrySpringJwtUserProviderTest {

class Fixture {
fun getSut(isSendDefaultPii: Boolean = true, jwt: Jwt? = null): SentrySpringJwtUserProvider {
val options = SentryOptions().apply {
this.isSendDefaultPii = isSendDefaultPii
}
val securityContext = mock<SecurityContext>()
if (jwt != null) {
val authentication = mock<Authentication>()
whenever(securityContext.authentication).thenReturn(authentication)
whenever(authentication.principal).thenReturn(jwt)
} else {
whenever(securityContext.authentication).thenReturn(null)
}
SecurityContextHolder.setContext(securityContext)
return SentrySpringJwtUserProvider(options)
}

fun getJwt(tokenValue: String, subject: String, issuer: String, issuedAt: Instant, expiresAt: Instant, audience: Collection<String>, headers: Map<String, Any>, claims: Map<String, Any>): Jwt {
return Jwt.withTokenValue(tokenValue)
.issuedAt(issuedAt)
.expiresAt(expiresAt)
.subject(subject)
.issuer(issuer)
.audience(audience)
.claims {
it.putAll(claims)
}
.headers {
it.putAll(headers)
}
.build()
}
}

private val fixture = Fixture()

@Test
fun `when send default pii is set to true, returns fully populated user`() {
val jwt = fixture.getJwt(
tokenValue = "token",
subject = "subject",
issuer = "issuer",
issuedAt = Instant.MIN,
expiresAt = Instant.MAX,
audience = listOf("audience"),
headers = mapOf("header" to "value"),
claims = mapOf("email" to "info@sentry.io")
)
val provider = fixture.getSut(true, jwt)
val user = provider.provideUser()
assertNotNull(user) {
assertEquals("subject", it.username)
assertEquals("email", "info@sentry.io")
assertEquals("subject", it.id)
assertEquals(jwt.claims, it.others)
}
}

@Test
fun `when send default pii is set to false, returns null`() {
val provider = fixture.getSut(false)
val user = provider.provideUser()
assertNull(user)
}

@Test
fun `when send default pii is set to true and security context is not set, returns null`() {
val provider = fixture.getSut(true)
val user = provider.provideUser()
assertNull(user)
}
}

0 comments on commit 6e8de78

Please sign in to comment.