Skip to content

Commit 3662235

Browse files
authored
support "multisegment" issuer (#31)
* feat: support "multisegment" issuer * i.e. issuerId can consist of multiple segments/paths
1 parent 93a5be0 commit 3662235

File tree

5 files changed

+111
-47
lines changed

5 files changed

+111
-47
lines changed

src/main/kotlin/no/nav/security/mock/oauth2/MockOAuth2Server.kt

+3-2
Original file line numberDiff line numberDiff line change
@@ -15,10 +15,12 @@ import java.net.InetAddress
1515
import java.net.URI
1616
import java.time.Duration
1717
import java.util.UUID
18+
import java.util.concurrent.TimeUnit
1819
import mu.KotlinLogging
1920
import no.nav.security.mock.oauth2.extensions.toAuthorizationEndpointUrl
2021
import no.nav.security.mock.oauth2.extensions.toEndSessionEndpointUrl
2122
import no.nav.security.mock.oauth2.extensions.toJwksUrl
23+
import no.nav.security.mock.oauth2.extensions.toOAuth2AuthorizationServerMetadataUrl
2224
import no.nav.security.mock.oauth2.extensions.toTokenEndpointUrl
2325
import no.nav.security.mock.oauth2.extensions.toWellKnownUrl
2426
import no.nav.security.mock.oauth2.http.MockWebServerWrapper
@@ -32,8 +34,6 @@ import no.nav.security.mock.oauth2.token.OAuth2TokenCallback
3234
import okhttp3.HttpUrl
3335
import okhttp3.mockwebserver.MockResponse
3436
import okhttp3.mockwebserver.RecordedRequest
35-
import java.lang.RuntimeException
36-
import java.util.concurrent.TimeUnit
3737

3838
private val log = KotlinLogging.logger { }
3939

@@ -84,6 +84,7 @@ open class MockOAuth2Server(
8484
} ?: throw UnsupportedOperationException("can only takeRequest when httpServer is of type MockWebServer")
8585

8686
fun wellKnownUrl(issuerId: String): HttpUrl = url(issuerId).toWellKnownUrl()
87+
fun oauth2AuthorizationServerMetadataUrl(issuerId: String): HttpUrl = url(issuerId).toOAuth2AuthorizationServerMetadataUrl()
8788
fun tokenEndpointUrl(issuerId: String): HttpUrl = url(issuerId).toTokenEndpointUrl()
8889
fun jwksUrl(issuerId: String): HttpUrl = url(issuerId).toJwksUrl()
8990
fun issuerUrl(issuerId: String): HttpUrl = url(issuerId)

src/main/kotlin/no/nav/security/mock/oauth2/extensions/HttpUrlExtensions.kt

+67-30
Original file line numberDiff line numberDiff line change
@@ -2,48 +2,85 @@ package no.nav.security.mock.oauth2.extensions
22

33
import com.nimbusds.oauth2.sdk.OAuth2Error
44
import no.nav.security.mock.oauth2.OAuth2Exception
5+
import no.nav.security.mock.oauth2.extensions.OAuth2Endpoints.AUTHORIZATION
6+
import no.nav.security.mock.oauth2.extensions.OAuth2Endpoints.DEBUGGER
7+
import no.nav.security.mock.oauth2.extensions.OAuth2Endpoints.DEBUGGER_CALLBACK
8+
import no.nav.security.mock.oauth2.extensions.OAuth2Endpoints.END_SESSION
9+
import no.nav.security.mock.oauth2.extensions.OAuth2Endpoints.JWKS
10+
import no.nav.security.mock.oauth2.extensions.OAuth2Endpoints.OAUTH2_WELL_KNOWN
11+
import no.nav.security.mock.oauth2.extensions.OAuth2Endpoints.OIDC_WELL_KNOWN
12+
import no.nav.security.mock.oauth2.extensions.OAuth2Endpoints.TOKEN
513
import okhttp3.HttpUrl
614

7-
fun HttpUrl.isWellKnownUrl(): Boolean = this == this.toWellKnownUrl() || this == this.toOAuth2AuthorizationServerMetadataUrl()
8-
fun HttpUrl.isAuthorizationEndpointUrl(): Boolean = this.withoutQuery() == this.toAuthorizationEndpointUrl()
9-
fun HttpUrl.isTokenEndpointUrl(): Boolean = this == this.toTokenEndpointUrl()
10-
fun HttpUrl.isEndSessionEndpointUrl(): Boolean = this.withoutQuery() == this.toEndSessionEndpointUrl()
11-
fun HttpUrl.isJwksUrl(): Boolean = this == this.toJwksUrl()
12-
fun HttpUrl.isDebuggerUrl(): Boolean = this.withoutQuery() == this.toDebuggerUrl()
13-
fun HttpUrl.isDebuggerCallbackUrl(): Boolean = this.withoutQuery() == this.toDebuggerCallbackUrl()
14-
15-
fun HttpUrl.toOAuth2AuthorizationServerMetadataUrl() = this.resolvePath("/${issuerId()}/.well-known/oauth-authorization-server")
16-
fun HttpUrl.toWellKnownUrl(): HttpUrl = this.resolvePath("/${issuerId()}/.well-known/openid-configuration")
17-
fun HttpUrl.toAuthorizationEndpointUrl(): HttpUrl = this.resolvePath("/${issuerId()}/authorize")
18-
fun HttpUrl.toEndSessionEndpointUrl(): HttpUrl = this.resolvePath("/${issuerId()}/endsession")
19-
fun HttpUrl.toTokenEndpointUrl(): HttpUrl = this.resolvePath("/${issuerId()}/token")
20-
fun HttpUrl.toJwksUrl(): HttpUrl = this.resolvePath("/${issuerId()}/jwks")
21-
fun HttpUrl.toIssuerUrl(): HttpUrl = this.resolvePath("/${issuerId()}")
22-
fun HttpUrl.toDebuggerUrl(): HttpUrl = this.resolvePath("/${issuerId()}/debugger")
23-
fun HttpUrl.toDebuggerCallbackUrl(): HttpUrl = this.resolvePath("/${issuerId()}/debugger/callback")
24-
25-
fun HttpUrl.issuerId(): String = this.pathSegments.getOrNull(0)
26-
?: throw OAuth2Exception(OAuth2Error.INVALID_REQUEST, "issuerId must be first segment in url path")
15+
object OAuth2Endpoints {
16+
const val OAUTH2_WELL_KNOWN = "/.well-known/oauth-authorization-server"
17+
const val OIDC_WELL_KNOWN = "/.well-known/openid-configuration"
18+
const val AUTHORIZATION = "/authorize"
19+
const val TOKEN = "/token"
20+
const val END_SESSION = "/endsession"
21+
const val JWKS = "/jwks"
22+
const val DEBUGGER = "/debugger"
23+
const val DEBUGGER_CALLBACK = "/debugger/callback"
2724

28-
fun HttpUrl.Builder.removeAllEncodedQueryParams(vararg params: String) =
29-
apply { params.forEach { removeAllEncodedQueryParameters(it) } }
25+
val all = listOf(
26+
OAUTH2_WELL_KNOWN,
27+
OIDC_WELL_KNOWN,
28+
AUTHORIZATION,
29+
TOKEN,
30+
END_SESSION,
31+
JWKS,
32+
DEBUGGER,
33+
DEBUGGER_CALLBACK
34+
)
35+
}
3036

31-
fun HttpUrl.match(path: String) =
32-
path.trimPath().let {
33-
this.pathSegments.containsAll(it.split("/"))
37+
fun HttpUrl.isWellKnownUrl(): Boolean = this.endsWith(OAUTH2_WELL_KNOWN) || this.endsWith(OIDC_WELL_KNOWN)
38+
fun HttpUrl.isAuthorizationEndpointUrl(): Boolean = this.endsWith(AUTHORIZATION)
39+
fun HttpUrl.isTokenEndpointUrl(): Boolean = this.endsWith(TOKEN)
40+
fun HttpUrl.isEndSessionEndpointUrl(): Boolean = this.endsWith(END_SESSION)
41+
fun HttpUrl.isJwksUrl(): Boolean = this.endsWith(JWKS)
42+
fun HttpUrl.isDebuggerUrl(): Boolean = this.endsWith(DEBUGGER)
43+
fun HttpUrl.isDebuggerCallbackUrl(): Boolean = this.endsWith(DEBUGGER_CALLBACK)
44+
45+
fun HttpUrl.toOAuth2AuthorizationServerMetadataUrl() = issuer(OAUTH2_WELL_KNOWN)
46+
fun HttpUrl.toWellKnownUrl(): HttpUrl = issuer(OIDC_WELL_KNOWN)
47+
fun HttpUrl.toAuthorizationEndpointUrl(): HttpUrl = issuer(AUTHORIZATION)
48+
fun HttpUrl.toEndSessionEndpointUrl(): HttpUrl = issuer(END_SESSION)
49+
fun HttpUrl.toTokenEndpointUrl(): HttpUrl = issuer(TOKEN)
50+
fun HttpUrl.toJwksUrl(): HttpUrl = issuer(JWKS)
51+
fun HttpUrl.toIssuerUrl(): HttpUrl = issuer()
52+
fun HttpUrl.toDebuggerUrl(): HttpUrl = issuer(DEBUGGER)
53+
fun HttpUrl.toDebuggerCallbackUrl(): HttpUrl = issuer(DEBUGGER_CALLBACK)
54+
55+
fun HttpUrl.issuerId(): String {
56+
val path = this.pathSegments.joinToString("/").trimPath()
57+
OAuth2Endpoints.all.forEach {
58+
if (path.endsWith(it)) {
59+
return path.substringBefore(it)
60+
}
3461
}
62+
return path
63+
}
64+
65+
fun HttpUrl.Builder.removeAllEncodedQueryParams(vararg params: String) =
66+
apply { params.forEach { removeAllEncodedQueryParameters(it) } }
3567

3668
fun HttpUrl.endsWith(path: String): Boolean = this.pathSegments.joinToString("/").endsWith(path.trimPath())
3769

3870
private fun String.trimPath() = removePrefix("/").removeSuffix("/")
3971

40-
private fun HttpUrl.withoutQuery(): HttpUrl = this.newBuilder().query(null).build()
72+
private fun HttpUrl.issuer(path: String = ""): HttpUrl =
73+
baseUrl().let {
74+
it.resolve(joinPaths(issuerId(), path))
75+
?: throw OAuth2Exception(OAuth2Error.INVALID_REQUEST, "cannot resolve path $path")
76+
}
77+
78+
private fun joinPaths(vararg path: String) =
79+
path.filter { it.isNotEmpty() }.joinToString("/") { it.trimPath() }
4180

42-
private fun HttpUrl.resolvePath(path: String): HttpUrl {
43-
return HttpUrl.Builder()
81+
private fun HttpUrl.baseUrl(): HttpUrl =
82+
HttpUrl.Builder()
4483
.scheme(this.scheme)
4584
.host(this.host)
4685
.port(this.port)
4786
.build()
48-
.resolve(path.removePrefix("/")) ?: throw OAuth2Exception(OAuth2Error.INVALID_REQUEST, "cannot resolve path $path")
49-
}

src/main/kotlin/no/nav/security/mock/oauth2/http/OAuth2HttpRequestHandler.kt

+6-4
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ import com.nimbusds.oauth2.sdk.GrantType.REFRESH_TOKEN
1010
import com.nimbusds.oauth2.sdk.OAuth2Error
1111
import com.nimbusds.oauth2.sdk.ParseException
1212
import com.nimbusds.openid.connect.sdk.AuthenticationRequest
13+
import java.net.URLEncoder
14+
import java.nio.charset.Charset
1315
import java.util.concurrent.BlockingQueue
1416
import java.util.concurrent.LinkedBlockingQueue
1517
import mu.KotlinLogging
@@ -41,8 +43,6 @@ import no.nav.security.mock.oauth2.login.Login
4143
import no.nav.security.mock.oauth2.login.LoginRequestHandler
4244
import no.nav.security.mock.oauth2.token.DefaultOAuth2TokenCallback
4345
import no.nav.security.mock.oauth2.token.OAuth2TokenCallback
44-
import java.net.URLEncoder
45-
import java.nio.charset.Charset
4646

4747
private val log = KotlinLogging.logger {}
4848

@@ -86,8 +86,10 @@ class OAuth2HttpRequestHandler(
8686

8787
private fun handleEndSessionRequest(request: OAuth2HttpRequest): OAuth2HttpResponse {
8888
log.debug("handle end session request $request")
89-
val postLogoutRedirectUri = request.url.queryParameter("post_logout_redirect_uri") ?: "https://www.nav.no"
90-
return redirect(postLogoutRedirectUri)
89+
val postLogoutRedirectUri = request.url.queryParameter("post_logout_redirect_uri")
90+
return postLogoutRedirectUri?.let {
91+
redirect(postLogoutRedirectUri)
92+
} ?: html("logged out")
9193
}
9294

9395
private fun handleAuthenticationRequest(request: OAuth2HttpRequest): OAuth2HttpResponse {

src/test/kotlin/no/nav/security/mock/oauth2/MockOAuth2ServerTest.kt

+2-11
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import io.kotest.matchers.shouldBe
1313
import io.kotest.matchers.string.shouldStartWith
1414
import java.net.URLEncoder
1515
import java.time.Duration
16+
import java.util.concurrent.TimeUnit
1617
import no.nav.security.mock.oauth2.extensions.verifySignatureAndIssuer
1718
import no.nav.security.mock.oauth2.http.OAuth2HttpResponse
1819
import no.nav.security.mock.oauth2.http.OAuth2TokenResponse
@@ -38,7 +39,6 @@ import org.junit.jupiter.api.AfterEach
3839
import org.junit.jupiter.api.BeforeEach
3940
import org.junit.jupiter.api.Disabled
4041
import org.junit.jupiter.api.Test
41-
import java.util.concurrent.TimeUnit
4242

4343
// TODO add more tests for exception handling
4444
class MockOAuth2ServerTest {
@@ -115,6 +115,7 @@ class MockOAuth2ServerTest {
115115
assertWellKnownResponseForIssuer("default")
116116
assertWellKnownResponseForIssuer("foo")
117117
assertWellKnownResponseForIssuer("bar")
118+
assertWellKnownResponseForIssuer("path1/path2/path3")
118119
}
119120

120121
@Test
@@ -136,16 +137,6 @@ class MockOAuth2ServerTest {
136137
assertThat(response.body?.string()).isEqualTo("some body")
137138
}
138139

139-
@Test
140-
fun noIssuerIdInUrlShouldReturn404() {
141-
val request: Request = Request.Builder()
142-
.url(server.baseUrl().newBuilder().addPathSegments("/.well-known/openid-configuration").build())
143-
.get()
144-
.build()
145-
146-
assertThat(client.newCall(request).execute().code).isEqualTo(404)
147-
}
148-
149140
@Test
150141
fun startAuthorizationCodeFlow() {
151142
val authorizationCodeFlowUrl = authorizationCodeFlowUrl(
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
package no.nav.security.mock.oauth2.extensions
2+
3+
import io.kotest.matchers.shouldBe
4+
import okhttp3.HttpUrl.Companion.toHttpUrl
5+
import org.junit.jupiter.api.Test
6+
7+
internal class HttpUrlExtensionsTest {
8+
9+
@Test
10+
fun `urls with no segments, one segment and multiple segments`() {
11+
"http://localhost".toHttpUrl().issuerId() shouldBe ""
12+
`verify oauth2 endpoint urls`("http://localhost")
13+
14+
"http://localhost/path1".toHttpUrl().issuerId() shouldBe "path1"
15+
`verify oauth2 endpoint urls`("http://localhost/path1")
16+
17+
"http://localhost/path1/path2".toHttpUrl().issuerId() shouldBe "path1/path2"
18+
`verify oauth2 endpoint urls`("http://localhost/path1/path2")
19+
}
20+
21+
private fun `verify oauth2 endpoint urls`(baseUrl: String) {
22+
val httpUrl = baseUrl.toHttpUrl()
23+
httpUrl.toIssuerUrl() shouldBe "$baseUrl".toHttpUrl()
24+
httpUrl.toWellKnownUrl() shouldBe "$baseUrl/.well-known/openid-configuration".toHttpUrl()
25+
httpUrl.toOAuth2AuthorizationServerMetadataUrl() shouldBe "$baseUrl/.well-known/oauth-authorization-server".toHttpUrl()
26+
httpUrl.toTokenEndpointUrl() shouldBe "$baseUrl/token".toHttpUrl()
27+
httpUrl.toAuthorizationEndpointUrl() shouldBe "$baseUrl/authorize".toHttpUrl()
28+
httpUrl.toDebuggerCallbackUrl() shouldBe "$baseUrl/debugger/callback".toHttpUrl()
29+
httpUrl.toDebuggerUrl() shouldBe "$baseUrl/debugger".toHttpUrl()
30+
httpUrl.toEndSessionEndpointUrl() shouldBe "$baseUrl/endsession".toHttpUrl()
31+
httpUrl.toJwksUrl() shouldBe "$baseUrl/jwks".toHttpUrl()
32+
}
33+
}

0 commit comments

Comments
 (0)