Skip to content

Commit 81560c6

Browse files
committed
cleanup of JWT handling + code comments
1 parent d3523bc commit 81560c6

4 files changed

Lines changed: 80 additions & 37 deletions

File tree

README.md

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,10 @@
44
Demonstrates using Spring Framework with Scala 3.
55

66
The biggest headaches of upgrading from Scala 2.13 to Scala 3 has been with the level of compatibility tools like IntellIJ offer, and the relatively small market share
7-
it has of Scala projects and libraries. Builds sometimes slow to a crawl or hang or randomly fail, only to succeed after a second or third retry.
7+
it has of Scala projects and libraries. Builds sometimes slow to a crawl, hang, or randomly fail, only to succeed after a second or third retry.
88

9-
The jury is still out on whether the migration is worthwhile for established projects, but
10-
as of 2025 I do feel like Scala 3 is the way to go for new projects.
9+
The jury is still out on whether the migration is worthwhile for established projects, but I do feel that Scala 3 has reached the point
10+
where it is the better choice for new projects.
1111

1212
:speech_balloon: **Questions / comments / suggestions are welcome in the [discussions](https://github.com/halfhp/ScalaSpringExperiment/discussions), or feel free to [contact me](mailto:halfhp@gmail.com) directly.**
1313

@@ -20,16 +20,18 @@ the app to use it.
2020
* Gradle[^1]
2121
* Spring Boot
2222
* Spring Security
23-
* Circe - JSON serialization and deserialization
23+
* [JWT Scala](https://github.com/jwt-scala/jwt-scala)
24+
* [Circe](https://github.com/circe/circe) - JSON serialization and deserialization
2425
* ~~ZIO~~ (Sticking with with Cats Effect out of preference, and because ZIO seems to have been [abandoned by its author](https://degoes.net/articles/splendid-scala-journey).)
2526
* Cats Effect
2627
* [Doobie](https://github.com/typelevel/doobie)[^2]
2728
* Flyway - Database schema definitions and migrations
2829
* ScalaTest
2930
* Mockito
3031

31-
[^1]: I chose Gradle over SBT initially out of curiosity. At this point I've used for several Scala projects now and have no regrets.
32-
SBT I believe might have some minor performance benefits, but you really cant Gradle in terms of features and support.
32+
[^1]: I've used for several Scala projects now and have few regrets. SBT I believe might have some minor performance benefits,
33+
but you really cant beat Gradle in terms of features and support. Having said that, it is possible that some of the build
34+
instability / Intellij bugginess I am experiencing is due to Gradle.
3335

3436
[^2]: So why Doobie and not one of the options that come packaged with Spring? Two main reasons: 1) Integrates seemlessly with Cats Effect and the IO monad, which is my
3537
preferred tool for structured concurrency. 2) Doobie is oriented around writing pure SQL and producing results as immutable case classes which I prefer over ORM approaches etc. that involve things like Hibernate, JPA, "live objects", etc.
@@ -129,6 +131,7 @@ what happens under load in situations where there are fewer cores than threads,
129131
## Spring Security
130132
* Add OAuth2 request/ refresh tokens
131133
* Add Oauth2 client to support third party authentication (Google, etc)
134+
* Add JTI to JWT tokens to support revocation
132135

133136
## Async Rest Controller
134137
Create an AsyncController that demonstrates adapting Spring's async programming model

src/main/scala/com/example/scalaspringexperiment/auth/JwtAuthManager.scala

Lines changed: 26 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,14 @@ package com.example.scalaspringexperiment.auth
33
import cats.data.EitherT
44
import cats.effect.IO
55
import cats.effect.unsafe.IORuntime
6-
import com.example.scalaspringexperiment.auth.JwtAuthManager.{AuthError, InvalidCredentials, LoginSuccess, ROLE_USER, RegisterSuccess, UserExists, UserNotFound}
6+
import com.example.scalaspringexperiment.auth.JwtAuthManager.{AuthError, DEFAULT_TOKEN_LIFETIME_SECONDS, InvalidCredentials, LoginSuccess, ROLE_USER, RegisterSuccess, UserExists, UserNotFound}
77
import com.example.scalaspringexperiment.entity.{Person, RegisteredUser}
88
import com.example.scalaspringexperiment.service.{PersonService, RegisteredUserService}
9+
import com.example.scalaspringexperiment.util.AsyncUtils
910
import org.springframework.context.annotation.Lazy
1011
import org.springframework.security.authentication.{ReactiveAuthenticationManager, UsernamePasswordAuthenticationToken}
1112
import org.springframework.security.core.authority.SimpleGrantedAuthority
12-
import org.springframework.security.core.{Authentication, GrantedAuthority}
13+
import org.springframework.security.core.Authentication
1314
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder
1415
import org.springframework.stereotype.Component
1516
import pdi.jwt.{JwtAlgorithm, JwtCirce, JwtClaim}
@@ -20,8 +21,11 @@ import java.time.Instant
2021
import scala.jdk.CollectionConverters.*
2122

2223
object JwtAuthManager {
23-
val ROLE_USER = "ROLE_USER"
24-
val ROLE_ADMIN = "ROLE_ADMIN"
24+
25+
val ROLE_USER: String = "ROLE_USER"
26+
val ROLE_ADMIN: String = "ROLE_ADMIN"
27+
val DEFAULT_TOKEN_LIFETIME_SECONDS: Int = 3600 * 24 * 30 // 30 days
28+
2529
sealed trait AuthError {
2630
val message: String
2731
}
@@ -62,28 +66,28 @@ class JwtAuthManager(
6266

6367
given theRuntime: IORuntime = runtime
6468

69+
import AsyncUtils.ioToMono
70+
6571
// TODO: this is very bad:
6672
private val secretKey: String = "secretKey"
6773
private val algo = JwtAlgorithm.HS256
6874

69-
override def authenticate(authentication: Authentication): Mono[Authentication] = {
70-
println(s"[AUTH DEBUG] Called with credentials: ${authentication.getCredentials}")
71-
try {
72-
val token = authentication.getCredentials.toString
73-
val claim = JwtCirce.decode(token, secretKey, Seq(algo)).get
74-
val authorities = Seq(new SimpleGrantedAuthority(ROLE_USER))
75-
val auth = new UsernamePasswordAuthenticationToken(
75+
override def authenticate(
76+
authentication: Authentication
77+
): Mono[Authentication] = {
78+
for {
79+
token <- IO(authentication.getCredentials.toString)
80+
claim <- IO(JwtCirce.decode(token, secretKey, Seq(algo)).get)
81+
user <- registeredUserService.findById(claim.subject.map(_.toLong).getOrElse(???))
82+
authorities <- IO(user.getOrElse(???).roles.map(role => new SimpleGrantedAuthority(role)))
83+
auth <- IO(new UsernamePasswordAuthenticationToken(
7684
claim.subject.get,
7785
null,
7886
authorities.asJava
79-
)
80-
Mono.just(auth)
81-
} catch {
82-
case e: Exception => Mono.empty()
83-
}
87+
))
88+
} yield auth
8489
}
8590

86-
8791
def generateTokenForRegisteredUser(
8892
user: RegisteredUser,
8993
expiration: Instant
@@ -124,7 +128,10 @@ class JwtAuthManager(
124128
case Some(p) => Right(p)
125129
case None => Left(UserNotFound("Person not found"))
126130
})
127-
jwtToken <- EitherT.liftF[IO, AuthError, String](generateTokenForRegisteredUser(registeredUser, Instant.now.plusSeconds(3600 * 30)))
131+
jwtToken <- EitherT.liftF[IO, AuthError, String](generateTokenForRegisteredUser(
132+
user = registeredUser,
133+
expiration = Instant.now.plusSeconds(DEFAULT_TOKEN_LIFETIME_SECONDS)
134+
))
128135
} yield LoginSuccess(
129136
registeredUser = registeredUser,
130137
person = person,
@@ -152,7 +159,7 @@ class JwtAuthManager(
152159
email = email,
153160
passwordHash = passwordHash,
154161
personId = person.id,
155-
roles = List() // TODO
162+
roles = List(ROLE_USER) // TODO
156163
)))
157164
jwtToken <- EitherT.liftF(generateTokenForRegisteredUser(registeredUser, Instant.now.plusSeconds(3600 * 30))) // 30 days
158165
} yield RegisterSuccess(

src/main/scala/com/example/scalaspringexperiment/controller/ControllerHelper.scala

Lines changed: 16 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,12 @@ package com.example.scalaspringexperiment.controller
22

33
import cats.effect.IO
44
import cats.effect.unsafe.IORuntime
5+
import com.example.scalaspringexperiment.util.AsyncUtils
56
import org.springframework.security.core.Authentication
67
import org.springframework.security.core.context.{ReactiveSecurityContextHolder, SecurityContext, SecurityContextHolder}
78
import org.springframework.stereotype.Component
89
import reactor.core.publisher.Mono
910

10-
import java.util.concurrent.CompletableFuture
1111
import scala.util.chaining.*
1212

1313
case class Ctx(
@@ -23,22 +23,19 @@ class ControllerHelper(
2323
) {
2424

2525
given rt: IORuntime = runtime
26-
27-
private given ioToMono[A](): Conversion[IO[A], Mono[A]] with {
28-
def apply(io: IO[A]): Mono[A] = {
29-
Mono.fromFuture(new CompletableFuture[A]().tap { cf =>
30-
io.unsafeRunAsync {
31-
case Right(value) => cf.complete(value)
32-
case Left(error) => cf.completeExceptionally(error)
33-
}
34-
})
35-
}
36-
}
26+
import AsyncUtils.ioToMono
3727

3828
private def fromIO [T](
3929
cb: () => IO[T],
4030
): Mono[T] = cb()
4131

32+
/**
33+
* Used by controller endpoints where authentication is not required. If the user is authenticated, the authentication
34+
* will be made available in the Ctx object. Also handles the conversion from IO[T] to Mono[T].
35+
* @param cb
36+
* @tparam T
37+
* @return
38+
*/
4239
def maybeAuth[T](
4340
cb: Ctx => IO[T]
4441
): Mono[T] = {
@@ -48,6 +45,13 @@ class ControllerHelper(
4845
.flatMap(auth => cb(Ctx(auth)))
4946
}
5047

48+
/**
49+
* Used by controller endpoints where authentication is required.
50+
* Also handles the conversion from IO[T] to Mono[T].
51+
* @param cb
52+
* @tparam T
53+
* @return
54+
*/
5155
def auth[T](
5256
cb: AuthCtx => IO[T]
5357
): Mono[T] = {
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
package com.example.scalaspringexperiment.util
2+
3+
import cats.effect.IO
4+
import cats.effect.unsafe.IORuntime
5+
import reactor.core.publisher.Mono
6+
7+
import java.util.concurrent.CompletableFuture
8+
import scala.util.chaining.*
9+
10+
object AsyncUtils {
11+
12+
/**
13+
* Convert an IO[A] to a Mono[A]. This implemntation should be efficient in the sense that while Mono is waiting for
14+
* the IO to resolve, it will not block a thread. The IO will be run on the provided IORuntime, and the Mono threadpool will be free
15+
* to process other Mono tasks while it waits.
16+
*/
17+
given ioToMono[A]()(
18+
using runtime: IORuntime
19+
): Conversion[IO[A], Mono[A]] with {
20+
def apply(io: IO[A]): Mono[A] = {
21+
Mono.fromFuture(new CompletableFuture[A]().tap { cf =>
22+
io.unsafeRunAsync {
23+
case Right(value) => cf.complete(value)
24+
case Left(error) => cf.completeExceptionally(error)
25+
}
26+
})
27+
}
28+
}
29+
}

0 commit comments

Comments
 (0)