Skip to content

Commit 5417d4e

Browse files
authored
Merge pull request navikt#256 from navikt/introspection_endpoint
feat: introspection_endpoint
2 parents 5779348 + 6a47ad8 commit 5417d4e

File tree

7 files changed

+223
-4
lines changed

7 files changed

+223
-4
lines changed

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,7 @@ A request to `http://localhost:8080/default/.well-known/openid-configuration` wi
8989
"token_endpoint":"http://localhost:8080/default/token",
9090
"userinfo_endpoint":"http://localhost:8080/default/userinfo",
9191
"jwks_uri":"http://localhost:8080/default/jwks",
92+
"introspection_endpoint":"http://localhost:8080/default/introspect",
9293
"response_types_supported":[
9394
"query",
9495
"fragment",

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

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import no.nav.security.mock.oauth2.extensions.OAuth2Endpoints.AUTHORIZATION
66
import no.nav.security.mock.oauth2.extensions.OAuth2Endpoints.DEBUGGER
77
import no.nav.security.mock.oauth2.extensions.OAuth2Endpoints.DEBUGGER_CALLBACK
88
import no.nav.security.mock.oauth2.extensions.OAuth2Endpoints.END_SESSION
9+
import no.nav.security.mock.oauth2.extensions.OAuth2Endpoints.INTROSPECT
910
import no.nav.security.mock.oauth2.extensions.OAuth2Endpoints.JWKS
1011
import no.nav.security.mock.oauth2.extensions.OAuth2Endpoints.OAUTH2_WELL_KNOWN
1112
import no.nav.security.mock.oauth2.extensions.OAuth2Endpoints.OIDC_WELL_KNOWN
@@ -21,6 +22,7 @@ object OAuth2Endpoints {
2122
const val END_SESSION = "/endsession"
2223
const val JWKS = "/jwks"
2324
const val USER_INFO = "/userinfo"
25+
const val INTROSPECT = "/introspect"
2426
const val DEBUGGER = "/debugger"
2527
const val DEBUGGER_CALLBACK = "/debugger/callback"
2628

@@ -32,6 +34,7 @@ object OAuth2Endpoints {
3234
END_SESSION,
3335
JWKS,
3436
USER_INFO,
37+
INTROSPECT,
3538
DEBUGGER,
3639
DEBUGGER_CALLBACK
3740
)
@@ -43,6 +46,7 @@ fun HttpUrl.isTokenEndpointUrl(): Boolean = this.endsWith(TOKEN)
4346
fun HttpUrl.isEndSessionEndpointUrl(): Boolean = this.endsWith(END_SESSION)
4447
fun HttpUrl.isJwksUrl(): Boolean = this.endsWith(JWKS)
4548
fun HttpUrl.isUserInfoUrl(): Boolean = this.endsWith(USER_INFO)
49+
fun HttpUrl.isIntrospectUrl(): Boolean = this.endsWith(INTROSPECT)
4650
fun HttpUrl.isDebuggerUrl(): Boolean = this.endsWith(DEBUGGER)
4751
fun HttpUrl.isDebuggerCallbackUrl(): Boolean = this.endsWith(DEBUGGER_CALLBACK)
4852

@@ -54,6 +58,7 @@ fun HttpUrl.toTokenEndpointUrl(): HttpUrl = issuer(TOKEN)
5458
fun HttpUrl.toJwksUrl(): HttpUrl = issuer(JWKS)
5559
fun HttpUrl.toIssuerUrl(): HttpUrl = issuer()
5660
fun HttpUrl.toUserInfoUrl(): HttpUrl = issuer(USER_INFO)
61+
fun HttpUrl.toIntrospectUrl(): HttpUrl = issuer(INTROSPECT)
5762
fun HttpUrl.toDebuggerUrl(): HttpUrl = issuer(DEBUGGER)
5863
fun HttpUrl.toDebuggerCallbackUrl(): HttpUrl = issuer(DEBUGGER_CALLBACK)
5964

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

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import no.nav.security.mock.oauth2.extensions.isAuthorizationEndpointUrl
99
import no.nav.security.mock.oauth2.extensions.isDebuggerCallbackUrl
1010
import no.nav.security.mock.oauth2.extensions.isDebuggerUrl
1111
import no.nav.security.mock.oauth2.extensions.isEndSessionEndpointUrl
12+
import no.nav.security.mock.oauth2.extensions.isIntrospectUrl
1213
import no.nav.security.mock.oauth2.extensions.isJwksUrl
1314
import no.nav.security.mock.oauth2.extensions.isTokenEndpointUrl
1415
import no.nav.security.mock.oauth2.extensions.isUserInfoUrl
@@ -17,6 +18,7 @@ import no.nav.security.mock.oauth2.extensions.keyValuesToMap
1718
import no.nav.security.mock.oauth2.extensions.requirePrivateKeyJwt
1819
import no.nav.security.mock.oauth2.extensions.toAuthorizationEndpointUrl
1920
import no.nav.security.mock.oauth2.extensions.toEndSessionEndpointUrl
21+
import no.nav.security.mock.oauth2.extensions.toIntrospectUrl
2022
import no.nav.security.mock.oauth2.extensions.toIssuerUrl
2123
import no.nav.security.mock.oauth2.extensions.toJwksUrl
2224
import no.nav.security.mock.oauth2.extensions.toTokenEndpointUrl
@@ -27,12 +29,13 @@ import no.nav.security.mock.oauth2.http.RequestType.DEBUGGER
2729
import no.nav.security.mock.oauth2.http.RequestType.DEBUGGER_CALLBACK
2830
import no.nav.security.mock.oauth2.http.RequestType.END_SESSION
2931
import no.nav.security.mock.oauth2.http.RequestType.FAVICON
32+
import no.nav.security.mock.oauth2.http.RequestType.INTROSPECT
3033
import no.nav.security.mock.oauth2.http.RequestType.JWKS
3134
import no.nav.security.mock.oauth2.http.RequestType.PREFLIGHT
3235
import no.nav.security.mock.oauth2.http.RequestType.TOKEN
3336
import no.nav.security.mock.oauth2.http.RequestType.UNKNOWN
34-
import no.nav.security.mock.oauth2.http.RequestType.WELL_KNOWN
3537
import no.nav.security.mock.oauth2.http.RequestType.USER_INFO
38+
import no.nav.security.mock.oauth2.http.RequestType.WELL_KNOWN
3639
import no.nav.security.mock.oauth2.missingParameter
3740
import okhttp3.Headers
3841
import okhttp3.HttpUrl
@@ -86,6 +89,7 @@ data class OAuth2HttpRequest(
8689
url.isTokenEndpointUrl() -> TOKEN
8790
url.isEndSessionEndpointUrl() -> END_SESSION
8891
url.isUserInfoUrl() -> USER_INFO
92+
url.isIntrospectUrl() -> INTROSPECT
8993
url.isJwksUrl() -> JWKS
9094
url.isDebuggerUrl() -> DEBUGGER
9195
url.isDebuggerCallbackUrl() -> DEBUGGER_CALLBACK
@@ -106,8 +110,9 @@ data class OAuth2HttpRequest(
106110
authorizationEndpoint = this.proxyAwareUrl().toAuthorizationEndpointUrl().toString(),
107111
tokenEndpoint = this.proxyAwareUrl().toTokenEndpointUrl().toString(),
108112
endSessionEndpoint = this.proxyAwareUrl().toEndSessionEndpointUrl().toString(),
113+
introspectionEndpoint = this.proxyAwareUrl().toIntrospectUrl().toString(),
109114
jwksUri = this.proxyAwareUrl().toJwksUrl().toString(),
110-
userInfoEndpoint = this.proxyAwareUrl().toUserInfoUrl().toString()
115+
userInfoEndpoint = this.proxyAwareUrl().toUserInfoUrl().toString(),
111116
)
112117

113118
internal fun proxyAwareUrl(): HttpUrl {
@@ -145,6 +150,7 @@ data class OAuth2HttpRequest(
145150
}
146151

147152
enum class RequestType {
148-
WELL_KNOWN, AUTHORIZATION, TOKEN, END_SESSION, JWKS,
149-
DEBUGGER, DEBUGGER_CALLBACK, FAVICON, PREFLIGHT, UNKNOWN, USER_INFO
153+
WELL_KNOWN, AUTHORIZATION, TOKEN, END_SESSION,
154+
JWKS, DEBUGGER, DEBUGGER_CALLBACK, FAVICON,
155+
PREFLIGHT, UNKNOWN, USER_INFO, INTROSPECT
150156
}

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ import no.nav.security.mock.oauth2.grant.RefreshTokenGrantHandler
3333
import no.nav.security.mock.oauth2.grant.RefreshTokenManager
3434
import no.nav.security.mock.oauth2.grant.TOKEN_EXCHANGE
3535
import no.nav.security.mock.oauth2.grant.TokenExchangeGrantHandler
36+
import no.nav.security.mock.oauth2.introspect.introspect
3637
import no.nav.security.mock.oauth2.invalidGrant
3738
import no.nav.security.mock.oauth2.login.Login
3839
import no.nav.security.mock.oauth2.login.LoginRequestHandler
@@ -83,6 +84,7 @@ class OAuth2HttpRequestHandler(private val config: OAuth2Config) {
8384
token()
8485
endSession()
8586
userInfo(config.tokenProvider)
87+
introspect(config.tokenProvider)
8688
preflight()
8789
get("/favicon.ico") { OAuth2HttpResponse(status = 200) }
8890
attach(debuggerRequestHandler)

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,8 @@ data class WellKnown(
3434
val userInfoEndpoint: String,
3535
@JsonProperty("jwks_uri")
3636
val jwksUri: String,
37+
@JsonProperty("introspection_endpoint")
38+
val introspectionEndpoint: String,
3739
@JsonProperty("response_types_supported")
3840
val responseTypesSupported: List<String> = listOf("query", "fragment", "form_post"),
3941
@JsonProperty("subject_types_supported")
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
package no.nav.security.mock.oauth2.introspect
2+
3+
import com.fasterxml.jackson.annotation.JsonInclude
4+
import com.fasterxml.jackson.annotation.JsonProperty
5+
import com.nimbusds.jwt.JWTClaimsSet
6+
import com.nimbusds.jwt.SignedJWT
7+
import com.nimbusds.oauth2.sdk.OAuth2Error
8+
import com.nimbusds.oauth2.sdk.id.Issuer
9+
import mu.KotlinLogging
10+
import no.nav.security.mock.oauth2.OAuth2Exception
11+
import no.nav.security.mock.oauth2.extensions.OAuth2Endpoints.INTROSPECT
12+
import no.nav.security.mock.oauth2.extensions.issuerId
13+
import no.nav.security.mock.oauth2.extensions.toIssuerUrl
14+
import no.nav.security.mock.oauth2.extensions.verifySignatureAndIssuer
15+
import no.nav.security.mock.oauth2.http.OAuth2HttpRequest
16+
import no.nav.security.mock.oauth2.http.Route
17+
import no.nav.security.mock.oauth2.http.json
18+
import no.nav.security.mock.oauth2.token.OAuth2TokenProvider
19+
import okhttp3.Headers
20+
21+
private val log = KotlinLogging.logger { }
22+
23+
internal fun Route.Builder.introspect(tokenProvider: OAuth2TokenProvider) =
24+
post(INTROSPECT) { request ->
25+
log.debug("received request to introspect endpoint, returning active and claims from token")
26+
27+
if (!request.headers.authenticated()) {
28+
val msg = "The client authentication was invalid"
29+
throw OAuth2Exception(OAuth2Error.INVALID_CLIENT.setDescription(msg), msg)
30+
}
31+
32+
request.verifyToken(tokenProvider)?.let {
33+
val claims = it.claims
34+
json(
35+
IntrospectResponse(
36+
true,
37+
claims["scope"].toString(),
38+
claims["client_id"].toString(),
39+
claims["username"].toString(),
40+
claims["token_type"].toString(),
41+
claims["exp"] as? Long,
42+
claims["iat"] as? Long,
43+
claims["nbf"] as? Long,
44+
claims["sub"].toString(),
45+
claims["aud"].toString(),
46+
claims["iss"].toString(),
47+
claims["jti"].toString()
48+
)
49+
)
50+
} ?: json(IntrospectResponse(false))
51+
}
52+
53+
private fun OAuth2HttpRequest.verifyToken(tokenProvider: OAuth2TokenProvider): JWTClaimsSet? {
54+
val tokenString = this.formParameters.get("token")
55+
val issuer = url.toIssuerUrl()
56+
val jwkSet = tokenProvider.publicJwkSet(issuer.issuerId())
57+
return try {
58+
SignedJWT.parse(tokenString).verifySignatureAndIssuer(Issuer(issuer.toString()), jwkSet)
59+
} catch (e: Exception) {
60+
log.debug("token_introspection: failed signature validation")
61+
return null
62+
}
63+
}
64+
65+
private fun Headers.authenticated(): Boolean {
66+
return this["Authorization"]?.let { authHeader ->
67+
authHeader.auth("Bearer ")?.isNotEmpty()
68+
?: authHeader.auth("Basic ")?.isNotEmpty()
69+
?: false
70+
} ?: false
71+
}
72+
73+
private fun String.auth(method: String): String? {
74+
return this.split(method)
75+
.takeIf { it.size == 2 }
76+
?.last()
77+
}
78+
79+
@JsonInclude(JsonInclude.Include.NON_NULL)
80+
data class IntrospectResponse(
81+
@JsonProperty("active")
82+
val active: Boolean,
83+
@JsonProperty("scope")
84+
val scope: String? = null,
85+
@JsonProperty("client_id")
86+
val clientId: String? = null,
87+
@JsonProperty("username")
88+
val username: String? = null,
89+
@JsonProperty("token_type")
90+
val tokenType: String? = null,
91+
@JsonProperty("exp")
92+
val exp: Long? = null,
93+
@JsonProperty("iat")
94+
val iat: Long? = null,
95+
@JsonProperty("nbf")
96+
val nbf: Long? = null,
97+
@JsonProperty("sub")
98+
val sub: String? = null,
99+
@JsonProperty("aud")
100+
val aud: String? = null,
101+
@JsonProperty("iss")
102+
val iss: String? = null,
103+
@JsonProperty("jti")
104+
val jti: String? = null,
105+
)
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
package no.nav.security.mock.oauth2.introspect
2+
3+
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
4+
import com.fasterxml.jackson.module.kotlin.readValue
5+
import io.kotest.assertions.asClue
6+
import io.kotest.assertions.throwables.shouldThrow
7+
import io.kotest.matchers.maps.shouldContain
8+
import io.kotest.matchers.maps.shouldContainAll
9+
import io.kotest.matchers.maps.shouldContainExactly
10+
import io.kotest.matchers.shouldBe
11+
import no.nav.security.mock.oauth2.OAuth2Exception
12+
import no.nav.security.mock.oauth2.extensions.OAuth2Endpoints.INTROSPECT
13+
import no.nav.security.mock.oauth2.http.OAuth2HttpRequest
14+
import no.nav.security.mock.oauth2.http.OAuth2HttpResponse
15+
import no.nav.security.mock.oauth2.http.routes
16+
import no.nav.security.mock.oauth2.token.OAuth2TokenProvider
17+
import okhttp3.Headers
18+
import okhttp3.HttpUrl.Companion.toHttpUrl
19+
import org.junit.jupiter.api.Test
20+
21+
internal class IntrospectTest {
22+
23+
@Test
24+
fun `introspect should return active and claims from bearer token`() {
25+
val issuerUrl = "http://localhost/default"
26+
val tokenProvider = OAuth2TokenProvider()
27+
val claims = mapOf(
28+
"iss" to issuerUrl,
29+
"client_id" to "yolo",
30+
"token_type" to "token",
31+
"sub" to "foo"
32+
)
33+
val token = tokenProvider.jwt(claims)
34+
println("token: " + token.jwtClaimsSet.toJSONObject())
35+
val request = request("$issuerUrl$INTROSPECT", token.serialize())
36+
37+
routes { introspect(tokenProvider) }.invoke(request).asClue {
38+
it.status shouldBe 200
39+
val response = it.parse<Map<String, Any>>()
40+
response shouldContainAll claims
41+
response shouldContain ("active" to true)
42+
}
43+
}
44+
45+
@Test
46+
fun `introspect should return active false when token is missing`() {
47+
val url = "http://localhost/default$INTROSPECT"
48+
49+
routes {
50+
introspect(OAuth2TokenProvider())
51+
}.invoke(request(url, null)).asClue {
52+
it.status shouldBe 200
53+
it.parse<Map<String, Any>>() shouldContainExactly mapOf("active" to false)
54+
}
55+
}
56+
57+
@Test
58+
fun `introspect should return active false when token is invalid`() {
59+
val url = "http://localhost/default$INTROSPECT"
60+
61+
routes {
62+
introspect(OAuth2TokenProvider())
63+
}.invoke(request(url, "invalid")).asClue {
64+
it.status shouldBe 200
65+
it.parse<Map<String, Any>>() shouldContainExactly mapOf("active" to false)
66+
}
67+
}
68+
69+
@Test
70+
fun `introspect should return 401 when no Authorization header is provided`() {
71+
val url = "http://localhost/default$INTROSPECT"
72+
73+
shouldThrow<OAuth2Exception> {
74+
routes {
75+
introspect(OAuth2TokenProvider())
76+
}.invoke(request(url, "invalid", "no auth"))
77+
}.asClue {
78+
it.errorObject?.code shouldBe "invalid_client"
79+
it.errorObject?.httpStatusCode shouldBe 401
80+
it.errorObject?.description shouldBe "The client authentication was invalid"
81+
}
82+
}
83+
84+
private inline fun <reified T> OAuth2HttpResponse.parse(): T = jacksonObjectMapper().readValue(checkNotNull(body))
85+
86+
private fun request(url: String, token: String?, auth: String = "Basic user=password"): OAuth2HttpRequest {
87+
return OAuth2HttpRequest(
88+
Headers.headersOf(
89+
"Authorization", auth,
90+
"Accept", "application/json",
91+
"Content-Type", "application/x-www-form-urlencoded"
92+
),
93+
method = "POST",
94+
url.toHttpUrl(),
95+
body = token?.let { "token=$it&token_type_hint=access_token" }
96+
)
97+
}
98+
}

0 commit comments

Comments
 (0)