Skip to content

Commit

Permalink
Merge pull request #256 from navikt/introspection_endpoint
Browse files Browse the repository at this point in the history
feat: introspection_endpoint
  • Loading branch information
ybelMekk authored May 16, 2022
2 parents 5779348 + 6a47ad8 commit 5417d4e
Show file tree
Hide file tree
Showing 7 changed files with 223 additions and 4 deletions.
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,7 @@ A request to `http://localhost:8080/default/.well-known/openid-configuration` wi
"token_endpoint":"http://localhost:8080/default/token",
"userinfo_endpoint":"http://localhost:8080/default/userinfo",
"jwks_uri":"http://localhost:8080/default/jwks",
"introspection_endpoint":"http://localhost:8080/default/introspect",
"response_types_supported":[
"query",
"fragment",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import no.nav.security.mock.oauth2.extensions.OAuth2Endpoints.AUTHORIZATION
import no.nav.security.mock.oauth2.extensions.OAuth2Endpoints.DEBUGGER
import no.nav.security.mock.oauth2.extensions.OAuth2Endpoints.DEBUGGER_CALLBACK
import no.nav.security.mock.oauth2.extensions.OAuth2Endpoints.END_SESSION
import no.nav.security.mock.oauth2.extensions.OAuth2Endpoints.INTROSPECT
import no.nav.security.mock.oauth2.extensions.OAuth2Endpoints.JWKS
import no.nav.security.mock.oauth2.extensions.OAuth2Endpoints.OAUTH2_WELL_KNOWN
import no.nav.security.mock.oauth2.extensions.OAuth2Endpoints.OIDC_WELL_KNOWN
Expand All @@ -21,6 +22,7 @@ object OAuth2Endpoints {
const val END_SESSION = "/endsession"
const val JWKS = "/jwks"
const val USER_INFO = "/userinfo"
const val INTROSPECT = "/introspect"
const val DEBUGGER = "/debugger"
const val DEBUGGER_CALLBACK = "/debugger/callback"

Expand All @@ -32,6 +34,7 @@ object OAuth2Endpoints {
END_SESSION,
JWKS,
USER_INFO,
INTROSPECT,
DEBUGGER,
DEBUGGER_CALLBACK
)
Expand All @@ -43,6 +46,7 @@ fun HttpUrl.isTokenEndpointUrl(): Boolean = this.endsWith(TOKEN)
fun HttpUrl.isEndSessionEndpointUrl(): Boolean = this.endsWith(END_SESSION)
fun HttpUrl.isJwksUrl(): Boolean = this.endsWith(JWKS)
fun HttpUrl.isUserInfoUrl(): Boolean = this.endsWith(USER_INFO)
fun HttpUrl.isIntrospectUrl(): Boolean = this.endsWith(INTROSPECT)
fun HttpUrl.isDebuggerUrl(): Boolean = this.endsWith(DEBUGGER)
fun HttpUrl.isDebuggerCallbackUrl(): Boolean = this.endsWith(DEBUGGER_CALLBACK)

Expand All @@ -54,6 +58,7 @@ fun HttpUrl.toTokenEndpointUrl(): HttpUrl = issuer(TOKEN)
fun HttpUrl.toJwksUrl(): HttpUrl = issuer(JWKS)
fun HttpUrl.toIssuerUrl(): HttpUrl = issuer()
fun HttpUrl.toUserInfoUrl(): HttpUrl = issuer(USER_INFO)
fun HttpUrl.toIntrospectUrl(): HttpUrl = issuer(INTROSPECT)
fun HttpUrl.toDebuggerUrl(): HttpUrl = issuer(DEBUGGER)
fun HttpUrl.toDebuggerCallbackUrl(): HttpUrl = issuer(DEBUGGER_CALLBACK)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import no.nav.security.mock.oauth2.extensions.isAuthorizationEndpointUrl
import no.nav.security.mock.oauth2.extensions.isDebuggerCallbackUrl
import no.nav.security.mock.oauth2.extensions.isDebuggerUrl
import no.nav.security.mock.oauth2.extensions.isEndSessionEndpointUrl
import no.nav.security.mock.oauth2.extensions.isIntrospectUrl
import no.nav.security.mock.oauth2.extensions.isJwksUrl
import no.nav.security.mock.oauth2.extensions.isTokenEndpointUrl
import no.nav.security.mock.oauth2.extensions.isUserInfoUrl
Expand All @@ -17,6 +18,7 @@ import no.nav.security.mock.oauth2.extensions.keyValuesToMap
import no.nav.security.mock.oauth2.extensions.requirePrivateKeyJwt
import no.nav.security.mock.oauth2.extensions.toAuthorizationEndpointUrl
import no.nav.security.mock.oauth2.extensions.toEndSessionEndpointUrl
import no.nav.security.mock.oauth2.extensions.toIntrospectUrl
import no.nav.security.mock.oauth2.extensions.toIssuerUrl
import no.nav.security.mock.oauth2.extensions.toJwksUrl
import no.nav.security.mock.oauth2.extensions.toTokenEndpointUrl
Expand All @@ -27,12 +29,13 @@ import no.nav.security.mock.oauth2.http.RequestType.DEBUGGER
import no.nav.security.mock.oauth2.http.RequestType.DEBUGGER_CALLBACK
import no.nav.security.mock.oauth2.http.RequestType.END_SESSION
import no.nav.security.mock.oauth2.http.RequestType.FAVICON
import no.nav.security.mock.oauth2.http.RequestType.INTROSPECT
import no.nav.security.mock.oauth2.http.RequestType.JWKS
import no.nav.security.mock.oauth2.http.RequestType.PREFLIGHT
import no.nav.security.mock.oauth2.http.RequestType.TOKEN
import no.nav.security.mock.oauth2.http.RequestType.UNKNOWN
import no.nav.security.mock.oauth2.http.RequestType.WELL_KNOWN
import no.nav.security.mock.oauth2.http.RequestType.USER_INFO
import no.nav.security.mock.oauth2.http.RequestType.WELL_KNOWN
import no.nav.security.mock.oauth2.missingParameter
import okhttp3.Headers
import okhttp3.HttpUrl
Expand Down Expand Up @@ -86,6 +89,7 @@ data class OAuth2HttpRequest(
url.isTokenEndpointUrl() -> TOKEN
url.isEndSessionEndpointUrl() -> END_SESSION
url.isUserInfoUrl() -> USER_INFO
url.isIntrospectUrl() -> INTROSPECT
url.isJwksUrl() -> JWKS
url.isDebuggerUrl() -> DEBUGGER
url.isDebuggerCallbackUrl() -> DEBUGGER_CALLBACK
Expand All @@ -106,8 +110,9 @@ data class OAuth2HttpRequest(
authorizationEndpoint = this.proxyAwareUrl().toAuthorizationEndpointUrl().toString(),
tokenEndpoint = this.proxyAwareUrl().toTokenEndpointUrl().toString(),
endSessionEndpoint = this.proxyAwareUrl().toEndSessionEndpointUrl().toString(),
introspectionEndpoint = this.proxyAwareUrl().toIntrospectUrl().toString(),
jwksUri = this.proxyAwareUrl().toJwksUrl().toString(),
userInfoEndpoint = this.proxyAwareUrl().toUserInfoUrl().toString()
userInfoEndpoint = this.proxyAwareUrl().toUserInfoUrl().toString(),
)

internal fun proxyAwareUrl(): HttpUrl {
Expand Down Expand Up @@ -145,6 +150,7 @@ data class OAuth2HttpRequest(
}

enum class RequestType {
WELL_KNOWN, AUTHORIZATION, TOKEN, END_SESSION, JWKS,
DEBUGGER, DEBUGGER_CALLBACK, FAVICON, PREFLIGHT, UNKNOWN, USER_INFO
WELL_KNOWN, AUTHORIZATION, TOKEN, END_SESSION,
JWKS, DEBUGGER, DEBUGGER_CALLBACK, FAVICON,
PREFLIGHT, UNKNOWN, USER_INFO, INTROSPECT
}
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import no.nav.security.mock.oauth2.grant.RefreshTokenGrantHandler
import no.nav.security.mock.oauth2.grant.RefreshTokenManager
import no.nav.security.mock.oauth2.grant.TOKEN_EXCHANGE
import no.nav.security.mock.oauth2.grant.TokenExchangeGrantHandler
import no.nav.security.mock.oauth2.introspect.introspect
import no.nav.security.mock.oauth2.invalidGrant
import no.nav.security.mock.oauth2.login.Login
import no.nav.security.mock.oauth2.login.LoginRequestHandler
Expand Down Expand Up @@ -83,6 +84,7 @@ class OAuth2HttpRequestHandler(private val config: OAuth2Config) {
token()
endSession()
userInfo(config.tokenProvider)
introspect(config.tokenProvider)
preflight()
get("/favicon.ico") { OAuth2HttpResponse(status = 200) }
attach(debuggerRequestHandler)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@ data class WellKnown(
val userInfoEndpoint: String,
@JsonProperty("jwks_uri")
val jwksUri: String,
@JsonProperty("introspection_endpoint")
val introspectionEndpoint: String,
@JsonProperty("response_types_supported")
val responseTypesSupported: List<String> = listOf("query", "fragment", "form_post"),
@JsonProperty("subject_types_supported")
Expand Down
105 changes: 105 additions & 0 deletions src/main/kotlin/no/nav/security/mock/oauth2/introspect/Introspect.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
package no.nav.security.mock.oauth2.introspect

import com.fasterxml.jackson.annotation.JsonInclude
import com.fasterxml.jackson.annotation.JsonProperty
import com.nimbusds.jwt.JWTClaimsSet
import com.nimbusds.jwt.SignedJWT
import com.nimbusds.oauth2.sdk.OAuth2Error
import com.nimbusds.oauth2.sdk.id.Issuer
import mu.KotlinLogging
import no.nav.security.mock.oauth2.OAuth2Exception
import no.nav.security.mock.oauth2.extensions.OAuth2Endpoints.INTROSPECT
import no.nav.security.mock.oauth2.extensions.issuerId
import no.nav.security.mock.oauth2.extensions.toIssuerUrl
import no.nav.security.mock.oauth2.extensions.verifySignatureAndIssuer
import no.nav.security.mock.oauth2.http.OAuth2HttpRequest
import no.nav.security.mock.oauth2.http.Route
import no.nav.security.mock.oauth2.http.json
import no.nav.security.mock.oauth2.token.OAuth2TokenProvider
import okhttp3.Headers

private val log = KotlinLogging.logger { }

internal fun Route.Builder.introspect(tokenProvider: OAuth2TokenProvider) =
post(INTROSPECT) { request ->
log.debug("received request to introspect endpoint, returning active and claims from token")

if (!request.headers.authenticated()) {
val msg = "The client authentication was invalid"
throw OAuth2Exception(OAuth2Error.INVALID_CLIENT.setDescription(msg), msg)
}

request.verifyToken(tokenProvider)?.let {
val claims = it.claims
json(
IntrospectResponse(
true,
claims["scope"].toString(),
claims["client_id"].toString(),
claims["username"].toString(),
claims["token_type"].toString(),
claims["exp"] as? Long,
claims["iat"] as? Long,
claims["nbf"] as? Long,
claims["sub"].toString(),
claims["aud"].toString(),
claims["iss"].toString(),
claims["jti"].toString()
)
)
} ?: json(IntrospectResponse(false))
}

private fun OAuth2HttpRequest.verifyToken(tokenProvider: OAuth2TokenProvider): JWTClaimsSet? {
val tokenString = this.formParameters.get("token")
val issuer = url.toIssuerUrl()
val jwkSet = tokenProvider.publicJwkSet(issuer.issuerId())
return try {
SignedJWT.parse(tokenString).verifySignatureAndIssuer(Issuer(issuer.toString()), jwkSet)
} catch (e: Exception) {
log.debug("token_introspection: failed signature validation")
return null
}
}

private fun Headers.authenticated(): Boolean {
return this["Authorization"]?.let { authHeader ->
authHeader.auth("Bearer ")?.isNotEmpty()
?: authHeader.auth("Basic ")?.isNotEmpty()
?: false
} ?: false
}

private fun String.auth(method: String): String? {
return this.split(method)
.takeIf { it.size == 2 }
?.last()
}

@JsonInclude(JsonInclude.Include.NON_NULL)
data class IntrospectResponse(
@JsonProperty("active")
val active: Boolean,
@JsonProperty("scope")
val scope: String? = null,
@JsonProperty("client_id")
val clientId: String? = null,
@JsonProperty("username")
val username: String? = null,
@JsonProperty("token_type")
val tokenType: String? = null,
@JsonProperty("exp")
val exp: Long? = null,
@JsonProperty("iat")
val iat: Long? = null,
@JsonProperty("nbf")
val nbf: Long? = null,
@JsonProperty("sub")
val sub: String? = null,
@JsonProperty("aud")
val aud: String? = null,
@JsonProperty("iss")
val iss: String? = null,
@JsonProperty("jti")
val jti: String? = null,
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
package no.nav.security.mock.oauth2.introspect

import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import com.fasterxml.jackson.module.kotlin.readValue
import io.kotest.assertions.asClue
import io.kotest.assertions.throwables.shouldThrow
import io.kotest.matchers.maps.shouldContain
import io.kotest.matchers.maps.shouldContainAll
import io.kotest.matchers.maps.shouldContainExactly
import io.kotest.matchers.shouldBe
import no.nav.security.mock.oauth2.OAuth2Exception
import no.nav.security.mock.oauth2.extensions.OAuth2Endpoints.INTROSPECT
import no.nav.security.mock.oauth2.http.OAuth2HttpRequest
import no.nav.security.mock.oauth2.http.OAuth2HttpResponse
import no.nav.security.mock.oauth2.http.routes
import no.nav.security.mock.oauth2.token.OAuth2TokenProvider
import okhttp3.Headers
import okhttp3.HttpUrl.Companion.toHttpUrl
import org.junit.jupiter.api.Test

internal class IntrospectTest {

@Test
fun `introspect should return active and claims from bearer token`() {
val issuerUrl = "http://localhost/default"
val tokenProvider = OAuth2TokenProvider()
val claims = mapOf(
"iss" to issuerUrl,
"client_id" to "yolo",
"token_type" to "token",
"sub" to "foo"
)
val token = tokenProvider.jwt(claims)
println("token: " + token.jwtClaimsSet.toJSONObject())
val request = request("$issuerUrl$INTROSPECT", token.serialize())

routes { introspect(tokenProvider) }.invoke(request).asClue {
it.status shouldBe 200
val response = it.parse<Map<String, Any>>()
response shouldContainAll claims
response shouldContain ("active" to true)
}
}

@Test
fun `introspect should return active false when token is missing`() {
val url = "http://localhost/default$INTROSPECT"

routes {
introspect(OAuth2TokenProvider())
}.invoke(request(url, null)).asClue {
it.status shouldBe 200
it.parse<Map<String, Any>>() shouldContainExactly mapOf("active" to false)
}
}

@Test
fun `introspect should return active false when token is invalid`() {
val url = "http://localhost/default$INTROSPECT"

routes {
introspect(OAuth2TokenProvider())
}.invoke(request(url, "invalid")).asClue {
it.status shouldBe 200
it.parse<Map<String, Any>>() shouldContainExactly mapOf("active" to false)
}
}

@Test
fun `introspect should return 401 when no Authorization header is provided`() {
val url = "http://localhost/default$INTROSPECT"

shouldThrow<OAuth2Exception> {
routes {
introspect(OAuth2TokenProvider())
}.invoke(request(url, "invalid", "no auth"))
}.asClue {
it.errorObject?.code shouldBe "invalid_client"
it.errorObject?.httpStatusCode shouldBe 401
it.errorObject?.description shouldBe "The client authentication was invalid"
}
}

private inline fun <reified T> OAuth2HttpResponse.parse(): T = jacksonObjectMapper().readValue(checkNotNull(body))

private fun request(url: String, token: String?, auth: String = "Basic user=password"): OAuth2HttpRequest {
return OAuth2HttpRequest(
Headers.headersOf(
"Authorization", auth,
"Accept", "application/json",
"Content-Type", "application/x-www-form-urlencoded"
),
method = "POST",
url.toHttpUrl(),
body = token?.let { "token=$it&token_type_hint=access_token" }
)
}
}

0 comments on commit 5417d4e

Please sign in to comment.