Skip to content

Commit 6490dc9

Browse files
majk-pmatwojcik
andauthored
Caching token introspection (#298)
* implement caching token introspection * add tests for caching token introspection * add docs Co-authored-by: Mateusz Wójcik <[email protected]>
1 parent d5c3936 commit 6490dc9

File tree

4 files changed

+213
-2
lines changed

4 files changed

+213
-2
lines changed

build.sbt

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -130,7 +130,7 @@ lazy val `oauth2-cache` = crossProject(JSPlatform, JVMPlatform)
130130
.jsSettings(jsSettings)
131131
.dependsOn(oauth2)
132132

133-
// oauth2-cache-cats doesn't have JS support because cats effect does not provide realTimeInstant on JS
133+
// oauth2-cache-cats doesn't have JS support because cats effect does not provide realTimeInstant on JS
134134
lazy val `oauth2-cache-cats` = project
135135
.settings(
136136
name := "sttp-oauth2-cache-cats",
@@ -146,7 +146,7 @@ lazy val `oauth2-cache-cats` = project
146146
)
147147
.dependsOn(`oauth2-cache`.jvm)
148148

149-
// oauth2-cache-ce2 doesn't have JS support because cats effect does not provide realTimeInstant on JS
149+
// oauth2-cache-ce2 doesn't have JS support because cats effect does not provide realTimeInstant on JS
150150
lazy val `oauth2-cache-ce2` = project
151151
.settings(
152152
name := "sttp-oauth2-cache-ce2",

docs/token-introspection.md

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
---
2+
sidebar_position: 5
3+
description: Token introspection
4+
---
5+
6+
Token introspection interface provides one method, that helps you ask the OAuth2 provider details about the token. The response is described in [rfc7662 section 2.2](https://datatracker.ietf.org/doc/html/rfc7662#section-2.2). The only guaranteed field is `active` that determines if the token is still valid.
7+
8+
```scala
9+
trait TokenIntrospection[F[_]] {
10+
11+
/** Introspects passed token in OAuth2 provider.
12+
*
13+
* Successful introspections returns `F[TokenIntrospectionResponse.IntrospectionResponse]`.
14+
*/
15+
def introspect(token: Secret[String]): F[Introspection.TokenIntrospectionResponse]
16+
17+
}
18+
```
19+
20+
The `oauth2-cache-cats` module provides the cached version of this interface `CachingTokenIntrospection` that allows you to limit the calls to the OAuth2 provider. To use it you need to provide regular `TokenIntrospection`, the cache implementation and the default expiration time, since the introspection response doesn't necessarily provide such information.
21+
22+
The cache implementation can be anything that implements `ExpiringCache` trait, for the out of the box solution use `CatsRefExpiringCache`, but keep in mind the memory consumption - that instance doesn't limit the list of tokens kept.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
package com.ocadotechnology.sttp.oauth2.cache.cats
2+
3+
import cats.data.OptionT
4+
import cats.effect.kernel.Clock
5+
import cats.effect.kernel.MonadCancelThrow
6+
import cats.implicits._
7+
import com.ocadotechnology.sttp.oauth2.Introspection.TokenIntrospectionResponse
8+
import com.ocadotechnology.sttp.oauth2.Secret
9+
import com.ocadotechnology.sttp.oauth2.TokenIntrospection
10+
import com.ocadotechnology.sttp.oauth2.cache.ExpiringCache
11+
12+
import java.time.Instant
13+
import scala.concurrent.duration._
14+
15+
final class CachingTokenIntrospection[F[_]: Clock: MonadCancelThrow](
16+
delegate: TokenIntrospection[F],
17+
cache: ExpiringCache[F, Secret[String], TokenIntrospectionResponse],
18+
defaultTimeToLive: FiniteDuration
19+
) extends TokenIntrospection[F] {
20+
21+
override def introspect(token: Secret[String]): F[TokenIntrospectionResponse] =
22+
getFromCache(token).flatMap {
23+
case Some(value) => value.pure[F]
24+
case None => fetchAndCache(token)
25+
}
26+
27+
private def getFromCache(token: Secret[String]): F[Option[TokenIntrospectionResponse]] = {
28+
for {
29+
now <- OptionT.liftF(Clock[F].realTime) // Using realTime since it's available cross platform
30+
response <- OptionT(cache.get(token))
31+
result <- OptionT.when[F, TokenIntrospectionResponse](responseIsUpToDate(now, response))(response)
32+
} yield result
33+
}.value
34+
35+
private def fetchAndCache(token: Secret[String]): F[TokenIntrospectionResponse] =
36+
for {
37+
now <- Clock[F].realTime
38+
nowInstant = Instant.ofEpochMilli(now.toMillis)
39+
result <- delegate
40+
.introspect(token)
41+
.flatTap { response =>
42+
cache.put(token, response, responseExpirationOrDefault(nowInstant, response))
43+
}
44+
} yield result
45+
46+
private def responseIsUpToDate(now: FiniteDuration, response: TokenIntrospectionResponse): Boolean =
47+
response.exp.map(_.toEpochMilli > now.toMillis).getOrElse(true)
48+
49+
private def responseExpirationOrDefault(now: Instant, response: TokenIntrospectionResponse): Instant = {
50+
val defaultExpirationTime = now.plusNanos(defaultTimeToLive.toNanos)
51+
response
52+
.exp
53+
.filter(_.isBefore(defaultExpirationTime))
54+
.getOrElse(defaultExpirationTime)
55+
}
56+
57+
}
58+
59+
object CachingTokenIntrospection {
60+
61+
def apply[F[_]: Clock: MonadCancelThrow](
62+
delegate: TokenIntrospection[F],
63+
cache: ExpiringCache[F, Secret[String], TokenIntrospectionResponse],
64+
defaultTimeToLive: FiniteDuration
65+
): CachingTokenIntrospection[F] =
66+
new CachingTokenIntrospection[F](delegate, cache, defaultTimeToLive)
67+
68+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
package com.ocadotechnology.sttp.oauth2.cache.cats
2+
3+
import cats.Functor
4+
import cats.effect.IO
5+
import cats.effect.Ref
6+
import cats.effect.kernel.Clock
7+
import cats.effect.kernel.Outcome.Succeeded
8+
import cats.effect.testkit.TestContext
9+
import cats.effect.testkit.TestInstances
10+
import cats.implicits._
11+
import com.ocadotechnology.sttp.oauth2.Introspection.TokenIntrospectionResponse
12+
import com.ocadotechnology.sttp.oauth2.Secret
13+
import com.ocadotechnology.sttp.oauth2.TokenIntrospection
14+
import org.scalatest.Assertion
15+
import org.scalatest.matchers.should.Matchers
16+
import org.scalatest.wordspec.AnyWordSpec
17+
18+
import java.time.Instant
19+
import scala.concurrent.duration._
20+
21+
class CachingTokenIntrospectionSpec extends AnyWordSpec with Matchers with TestInstances {
22+
private implicit val ticker: Ticker = Ticker(TestContext())
23+
24+
private val testToken: Secret[String] = Secret("secret")
25+
26+
private def testToken60Seconds(now: Instant): TokenIntrospectionResponse = TokenIntrospectionResponse(
27+
active = true,
28+
exp = Some(now.plusSeconds(60))
29+
)
30+
31+
private def testToken5Seconds(now: Instant): TokenIntrospectionResponse = TokenIntrospectionResponse(
32+
active = true,
33+
exp = Some(now.plusSeconds(5))
34+
)
35+
36+
private def testToken2Seconds(now: Instant): TokenIntrospectionResponse = TokenIntrospectionResponse(
37+
active = true,
38+
exp = Some(now.plusSeconds(2))
39+
)
40+
41+
private val inactiveToken: TokenIntrospectionResponse = TokenIntrospectionResponse(active = false)
42+
43+
private val testFailedResponse: TokenIntrospectionResponse = TokenIntrospectionResponse(active = false)
44+
45+
"CachingTokenIntrospection" should {
46+
"delegate token retrieval on first call" in runTest { case (delegate, cachingIntrospection) =>
47+
for {
48+
now <- Clock[IO].realTimeInstant
49+
_ <- delegate.updateTokenResponse(testToken, testToken60Seconds(now))
50+
result <- cachingIntrospection.introspect(testToken)
51+
} yield result shouldBe testToken60Seconds(now)
52+
}
53+
54+
"return cached response if it's not yet expired" in runTest { case (delegate, cachingIntrospection) =>
55+
for {
56+
now <- Clock[IO].realTimeInstant
57+
_ <- delegate.updateTokenResponse(testToken, testToken60Seconds(now))
58+
_ <- cachingIntrospection.introspect(testToken)
59+
_ <- Clock[IO].sleep(3.seconds)
60+
_ <- delegate.updateTokenResponse(testToken, testToken5Seconds(now))
61+
result <- cachingIntrospection.introspect(testToken)
62+
} yield result shouldBe testToken60Seconds(now)
63+
}
64+
65+
"fetch the new introspection result if the cache has expired" in runTest { case (delegate, cachingIntrospection) =>
66+
for {
67+
now <- Clock[IO].realTimeInstant
68+
_ <- delegate.updateTokenResponse(testToken, testToken60Seconds(now))
69+
_ <- cachingIntrospection.introspect(testToken)
70+
_ <- Clock[IO].sleep(61.seconds)
71+
_ <- delegate.updateTokenResponse(testToken, inactiveToken)
72+
result <- cachingIntrospection.introspect(testToken)
73+
} yield result shouldBe inactiveToken
74+
}
75+
76+
"fetch the new introspection result if the cache has reached the default ttl" in runTest { case (delegate, cachingIntrospection) =>
77+
for {
78+
now <- Clock[IO].realTimeInstant
79+
_ <- delegate.updateTokenResponse(testToken, testToken60Seconds(now))
80+
_ <- cachingIntrospection.introspect(testToken)
81+
_ <- Clock[IO].sleep(58.seconds)
82+
_ <- delegate.updateTokenResponse(testToken, testToken2Seconds(now))
83+
result <- cachingIntrospection.introspect(testToken)
84+
} yield result shouldBe testToken2Seconds(now)
85+
}
86+
87+
}
88+
89+
def runTest(test: ((TestTokenIntrospection[IO], CachingTokenIntrospection[IO])) => IO[Assertion]): Assertion =
90+
unsafeRun(prepareTest.flatMap(test)) match {
91+
case Succeeded(Some(assertion)) => assertion
92+
case wrongResult => fail(s"Test should finish successfully. Instead ended with $wrongResult")
93+
}
94+
95+
private def prepareTest: IO[(TestTokenIntrospection[IO], CachingTokenIntrospection[IO])] =
96+
for {
97+
state <- Ref.of[IO, Map[Secret[String], TokenIntrospectionResponse]](Map.empty)
98+
cache <- CatsRefExpiringCache[IO, Secret[String], TokenIntrospectionResponse]
99+
delegate = TestTokenIntrospection(state)
100+
cachingIntrospection = CachingTokenIntrospection[IO](delegate, cache, 5.seconds)
101+
} yield (delegate, cachingIntrospection)
102+
103+
trait TestTokenIntrospection[F[_]] extends TokenIntrospection[F] {
104+
def introspect(token: Secret[String]): F[TokenIntrospectionResponse]
105+
def updateTokenResponse(token: Secret[String], response: TokenIntrospectionResponse): F[Unit]
106+
}
107+
108+
object TestTokenIntrospection {
109+
110+
def apply[F[_]: Functor](ref: Ref[F, Map[Secret[String], TokenIntrospectionResponse]]): TestTokenIntrospection[F] =
111+
new TestTokenIntrospection[F] {
112+
def introspect(token: Secret[String]): F[TokenIntrospectionResponse] =
113+
ref.get.map(_.get(token).getOrElse(testFailedResponse))
114+
115+
def updateTokenResponse(token: Secret[String], response: TokenIntrospectionResponse): F[Unit] =
116+
ref.update(state => state + (token -> response))
117+
}
118+
119+
}
120+
121+
}

0 commit comments

Comments
 (0)