Skip to content

Commit 1893e52

Browse files
authored
feat: allow custom claims in login form (#113)
Replace the acr field in the login form with a text area that is supposed to be filled with a JSON structure containing one or more claims.
1 parent 7b3ca95 commit 1893e52

File tree

5 files changed

+84
-10
lines changed

5 files changed

+84
-10
lines changed

src/main/kotlin/no/nav/security/mock/oauth2/grant/AuthorizationCodeGrantHandler.kt

+17-1
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
package no.nav.security.mock.oauth2.grant
22

3+
import com.fasterxml.jackson.core.JsonProcessingException
4+
import com.fasterxml.jackson.databind.ObjectMapper
5+
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
6+
import com.fasterxml.jackson.module.kotlin.readValue
37
import com.nimbusds.jwt.SignedJWT
48
import com.nimbusds.oauth2.sdk.AuthorizationCode
59
import com.nimbusds.oauth2.sdk.OAuth2Error
@@ -19,6 +23,7 @@ import no.nav.security.mock.oauth2.token.OAuth2TokenProvider
1923
import okhttp3.HttpUrl
2024

2125
private val log = KotlinLogging.logger {}
26+
private val jsonMapper: ObjectMapper = jacksonObjectMapper()
2227

2328
internal class AuthorizationCodeHandler(
2429
private val tokenProvider: OAuth2TokenProvider,
@@ -97,7 +102,18 @@ internal class AuthorizationCodeHandler(
97102
override fun audience(tokenRequest: TokenRequest): List<String> = OAuth2TokenCallback.audience(tokenRequest)
98103
override fun addClaims(tokenRequest: TokenRequest): Map<String, Any> =
99104
OAuth2TokenCallback.addClaims(tokenRequest).toMutableMap().apply {
100-
login.acr?.let { put("acr", it) }
105+
login.claims?.let {
106+
try {
107+
jsonMapper.readTree(it)
108+
.fields()
109+
.forEach { field ->
110+
put(field.key, jsonMapper.readValue(field.value.toString()))
111+
}
112+
}
113+
catch (exception: JsonProcessingException) {
114+
log.warn("claims value $it could not be processed as JSON, details: ${exception.message}")
115+
}
116+
}
101117
}
102118

103119
override fun tokenExpiry(): Long = OAuth2TokenCallback.tokenExpiry()

src/main/kotlin/no/nav/security/mock/oauth2/login/LoginRequestHandler.kt

+2-2
Original file line numberDiff line numberDiff line change
@@ -10,11 +10,11 @@ class LoginRequestHandler(private val templateMapper: TemplateMapper) {
1010
fun loginSubmit(httpRequest: OAuth2HttpRequest): Login {
1111
val formParameters = httpRequest.formParameters
1212
val username = checkNotNull(formParameters.get("username"))
13-
return Login(username, formParameters.get("acr"))
13+
return Login(username, formParameters.get("claims"))
1414
}
1515
}
1616

1717
data class Login(
1818
val username: String,
19-
val acr: String? = null
19+
val claims: String? = null
2020
)

src/main/resources/templates/css/custom.css

+4
Original file line numberDiff line numberDiff line change
@@ -116,3 +116,7 @@ pre > code {
116116
}
117117
}
118118

119+
.claims {
120+
resize: vertical;
121+
height: fit-content;
122+
}

src/main/resources/templates/login.ftl

+5-3
Original file line numberDiff line numberDiff line change
@@ -16,9 +16,11 @@
1616
autofocus="on">
1717
</label>
1818
<label>
19-
<input class="u-full-width" required type="text" name="acr"
20-
placeholder="Optional 'acr' claim value"
21-
autofocus="on">
19+
<textarea class="u-full-width claims" name="claims" rows="15"
20+
placeholder="Optional claims JSON value, example:
21+
{
22+
&quot;acr&quot;: &quot;reference&quot;
23+
}" autofocus="on"></textarea>
2224
</label>
2325
<input class="button-primary" type="submit" value="Sign-in">
2426
</form>

src/test/kotlin/no/nav/security/mock/oauth2/grant/AuthorizationCodeHandlerTest.kt

+56-4
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,28 @@
11
package no.nav.security.mock.oauth2.grant
22

3+
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
34
import com.nimbusds.jwt.SignedJWT
45
import com.nimbusds.oauth2.sdk.ResponseMode
56
import com.nimbusds.openid.connect.sdk.AuthenticationRequest
67
import io.kotest.assertions.asClue
78
import io.kotest.matchers.shouldBe
9+
import io.kotest.matchers.shouldNotBe
810
import no.nav.security.mock.oauth2.http.OAuth2HttpRequest
911
import no.nav.security.mock.oauth2.login.Login
1012
import no.nav.security.mock.oauth2.testutils.authenticationRequest
13+
import no.nav.security.mock.oauth2.testutils.claims
1114
import no.nav.security.mock.oauth2.testutils.subject
1215
import no.nav.security.mock.oauth2.token.DefaultOAuth2TokenCallback
1316
import no.nav.security.mock.oauth2.token.OAuth2TokenProvider
1417
import okhttp3.Headers
1518
import okhttp3.HttpUrl
1619
import okhttp3.HttpUrl.Companion.toHttpUrl
1720
import org.junit.jupiter.api.Test
21+
import org.junit.jupiter.params.ParameterizedTest
22+
import org.junit.jupiter.params.provider.Arguments
23+
import org.junit.jupiter.params.provider.MethodSource
24+
import org.junit.jupiter.params.provider.ValueSource
25+
import java.util.stream.Stream
1826

1927
internal class AuthorizationCodeHandlerTest {
2028
private val handler = AuthorizationCodeHandler(OAuth2TokenProvider(), RefreshTokenManager())
@@ -39,16 +47,60 @@ internal class AuthorizationCodeHandlerTest {
3947

4048
@Test
4149
fun `token response with login should return id_token and access_token containing username from login as sub`() {
42-
val code: String = handler.authorizationCodeResponse(
43-
authenticationRequest = "http://authorizationendpoint".toHttpUrl().authenticationRequest().asNimbusAuthRequest(),
44-
login = Login("foo")
45-
).authorizationCode.value
50+
val code: String = handler.retrieveAuthorizationCode(Login("foo"))
4651

4752
handler.tokenResponse(tokenRequest(code = code), "http://myissuer".toHttpUrl(), DefaultOAuth2TokenCallback()).asClue {
4853
SignedJWT.parse(it.idToken).subject shouldBe "foo"
4954
}
5055
}
5156

57+
@ParameterizedTest
58+
@MethodSource("jsonClaimsProvider")
59+
fun `token response with login including claims should return access_token containing claims from login`(claims: String, expectedClaimKey: String, expectedClaimValue: String) {
60+
val code: String = handler.retrieveAuthorizationCode(Login("foo", claims))
61+
62+
handler.tokenResponse(tokenRequest(code = code), "http://myissuer".toHttpUrl(), DefaultOAuth2TokenCallback()).asClue {
63+
val claim = SignedJWT.parse(it.idToken).claims[expectedClaimKey]
64+
claim shouldNotBe null
65+
jacksonObjectMapper().writeValueAsString(claim) shouldBe expectedClaimValue
66+
}
67+
}
68+
69+
companion object {
70+
@JvmStatic
71+
fun jsonClaimsProvider(): Stream<Arguments> = Stream.of(
72+
Arguments.of("{ \"acr\": \"value\" }", "acr", "\"value\""),
73+
Arguments.of("{ \"acr\": { \"reference\": { \"id\": \"value\" } } }", "acr", "{\"reference\":{\"id\":\"value\"}}")
74+
)
75+
}
76+
77+
@Test
78+
fun `token response with login including multiple claims should return access_token containing all claims from login`() {
79+
val code: String = handler.retrieveAuthorizationCode(Login("foo", "{ \"acr\": \"value1\", \"abc\": \"value2\" }"))
80+
81+
handler.tokenResponse(tokenRequest(code = code), "http://myissuer".toHttpUrl(), DefaultOAuth2TokenCallback()).asClue {
82+
val claims = SignedJWT.parse(it.idToken).claims
83+
claims["acr"] shouldBe "value1"
84+
claims["abc"] shouldBe "value2"
85+
}
86+
}
87+
88+
@ParameterizedTest
89+
@ValueSource(strings = ["{", "[]", "[\"claim\"]", "{}"])
90+
fun `token response with login including invalid JSON for claims parsing should return access_token containing no additional claims`(claimsValue: String) {
91+
val code: String = handler.retrieveAuthorizationCode(Login("foo", claimsValue))
92+
93+
handler.tokenResponse(tokenRequest(code = code), "http://myissuer".toHttpUrl(), DefaultOAuth2TokenCallback()).asClue {
94+
SignedJWT.parse(it.idToken).claims.count() shouldBe 10
95+
}
96+
}
97+
98+
private fun AuthorizationCodeHandler.retrieveAuthorizationCode(login: Login): String =
99+
authorizationCodeResponse(
100+
authenticationRequest = "http://authorizationendpoint".toHttpUrl().authenticationRequest().asNimbusAuthRequest(),
101+
login = login
102+
).authorizationCode.value
103+
52104
private fun HttpUrl.asNimbusAuthRequest(): AuthenticationRequest = AuthenticationRequest.parse(this.toUri())
53105

54106
private fun tokenRequest(

0 commit comments

Comments
 (0)