-
Notifications
You must be signed in to change notification settings - Fork 60
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Merge pull request #256 from navikt/introspection_endpoint
feat: introspection_endpoint
- Loading branch information
Showing
7 changed files
with
223 additions
and
4 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
105 changes: 105 additions & 0 deletions
105
src/main/kotlin/no/nav/security/mock/oauth2/introspect/Introspect.kt
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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, | ||
) |
98 changes: 98 additions & 0 deletions
98
src/test/kotlin/no/nav/security/mock/oauth2/introspect/IntrospectTest.kt
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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" } | ||
) | ||
} | ||
} |