В это пример я буду рассматривать только парсинг и валидацию токенов что уже пришли в мое API в Authorization хедере. Для генерации токенов, регистрации пользователей и прочего SSO есть много готовых решений которые легко установить или даже устанавливать не надо. Например, Auth0, Keyckloak, IdentityServer4. В пример е буду работать с Tapir который может использовать в качестве бекенда http4s, Akk HTTP, Netty, Finatra, Play, ZIO Http, Armeria. Я буду использовать Tapir + Http4s.
Полистав интернет я выяснил что самой полулярной библиотекой для этого является PAC4J который внутри себя использует oauth2-oidc-sdk.
Исходный код лежит тут - https://gitlab.com/VictorWinbringer/scalaauth
Сначала нужно установить sbt. Проще всего по этой инструкции https://www.scala-lang.org/download/
Создаем проект командой (работает и на Windows).
sbt new https://codeberg.org/wegtam/http4s-tapir.g8.git
Проект создаться сразу с поддержкой БД Постгрес. Сейчас нам БД не нужна поэтому закомментирую строку, запускающую миграции БД в файле Server.scala
//_ <- migrator.migrate(dbConfig.url, dbConfig.user, dbConfig.pass)
Добавим в файл build.sbt oauth2-oidc-sdk
"com.nimbusds" % "oauth2-oidc-sdk" % "9.27"
Создаем тип для нашего токена. Тут используем GitHub - fthomas/refined: Refinement types for Scala для описания ValueObject нашего который может быть только не пустой строкой.
type AuthToken = String Refined NonEmpty
object AuthToken extends RefinedTypeOps[AuthToken, String] with CatsRefinedTypeOpsSyntax
создаем тип для ошибки
final case class Error(msg: String, statusCode: StatusCode) extends Exception
Создаем базовый эндпойнт для эндпойнтов требующих авторизацию
val baseEndpoint = endpoint
.in("api")
.in("v1")
.errorOut(stringBody.and(statusCode).mapTo[Error])
val baseEndpointWithAuth = baseEndpoint
.in(auth.bearer[AuthToken]())
Сама логика валидации. Функция parse. Если токен валидный, то возвращается идентификатор пользователя. Поле “sub” токена. Иначе будет возвращена ошибка
def authWithToken(token: AuthToken) = Try[String]({
//Адрес SSO сервера
val iss = new Issuer("https://dev-t-ca3k92.us.auth0.com/")
//Идентификатор нашего клиента он же Audience
val clientID = new ClientID("http://127.0.0.1:8888")
val jwsAlg = JWSAlgorithm.RS256
//Адрес по которому загружать данные для JWK
val jwkSetURL = new URL("https://dev-t-ca3k92.us.auth0.com/.well-known/jwks.json")
val validator = new IDTokenValidator(iss, clientID, jwsAlg, jwkSetURL, new DefaultResourceRetriever())
val idToken = JWTParser.parse(token.value)
//Валидируем токен и получаем сохраненные в нем данные.
//Идет проверка времени жизни и других параметров
val claims = validator.validate(idToken, null)
claims.getSubject().getValue
}).toEither
.swap
.map(x => x.getMessage)
.swap
.flatMap(x => UserId.from(x))
Исползуем его в эндпойне что будет возвращать этот самый идентификатор пользователя
private val getMySub = BaseController.baseEndpointWithAuth
.in("hello")
.tag("Hello")
.in("sub")
.serverLogic(x=>IO(
authWithToken(x)
.swap
.map(x=> Error(x.getMessage, StatusCode.Unauthorized))
.swap
))
Так же Tapir предоставляет возможность делать цепочку вычислений через метод serverLogicForCurrent
Например, в первом методе можно распарсить токен а во втором уже проверить роли пользователя и уже работать с нужными данными. Например, вот так.
//Возвращает 401 если не удалось провалидировать токен пользователя
def authenticate(token: AuthToken): IO[Either[Error, User]] = ???
//Возвращает 403 если у пользователя нет ни одной роли из писка
def authorize(user: User, roles: Seq[String]): IO[Either[Error, User]] = ???
private val getUserProfile = BaseController.baseEndpointWithAuth
.get
.in("hello")
.tag("Hello")
.in("profile")
.serverLogicForCurrent(authenticate)
.out(jsonBody[User])
.serverLogic({ case (user, _) => authorize(user, Seq("admin", "root")) })
Чтобы сгенерировать сам токен доступа заходим в наш дашбоард Auth0 и добавляем приложение.
https://manage.auth0.com/dashboard/
Потом добавляем API
Переходим в раздел для тестирования и копируем токен
Дальше уже можем его использовать в заголовке AccesToken: Bearer {наш токен}