Awesome
OATH - Scala JWT
OATH provides an easy way for Rest API Applications to manipulate JWTs securely in complex systems.
- Customize registered claims via configuration.
- Create a variety of JWT tokens with different configuration for each use case
- Token encryption.
SBT Dependencies
libraryDependencies += "io.github.scala-jwt" %% "oath-core" % "0.0.0"
Json Converters
libraryDependencies += "io.github.scala-jwt" %% "oath-circe" % "0.0.0"
libraryDependencies += "io.github.scala-jwt" %% "oath-jsoniter-scala" % "0.0.0"
Supported Algorithms
JWS | Algorithm | Description |
---|---|---|
HS256 | HMAC256 | HMAC with SHA-256 |
HS384 | HMAC384 | HMAC with SHA-384 |
HS512 | HMAC512 | HMAC with SHA-512 |
RS256 | RSA256 | RSASSA-PKCS1-v1_5 with SHA-256 |
RS384 | RSA384 | RSASSA-PKCS1-v1_5 with SHA-384 |
RS512 | RSA512 | RSASSA-PKCS1-v1_5 with SHA-512 |
ES256 | ECDSA256 | ECDSA with curve P-256 and SHA-256 |
ES384 | ECDSA384 | ECDSA with curve P-384 and SHA-384 |
ES512 | ECDSA512 | ECDSA with curve P-521 and SHA-512 |
Introduction
Oath is an extension on top of JWT. Oath will allow you to create custom tokens from scala ADT Enum
associated with different properties and hide the
boilerplate
in configuration files. Oath macros are inspired from Enumeratum in order to collect
the information needed for the custom Enum
.
Oath Overview
JWT (JSON Web Token)
The oath-core
depends on oath0/java-jwt library. Is inspired
by akka-http-session
& jwt-scala
if you have already used those libraries you would probably find your self familiar with this API.
JWT API Overview
In a microservice architecture you could have more than on service issuing or verifying tokens. The library is being design to follow this principle by splitting the requirements to different APIs.
JwtIssuer Overview
All registered claims documented in RFC-7519 are provided with optional values, therefore the library doesn't enforce you to use them.
final case class RegisteredClaims(
iss: Option[String] = None,
sub: Option[String] = None,
aud: Seq[String] = Seq.empty,
exp: Option[Instant] = None,
nbf: Option[Instant] = None,
iat: Option[Instant] = None,
jti: Option[String] = None
)
Claims is more than Registered Claims though. Therefore, if the business requirements requires extra claims to be able
to authenticate & authorize the clients,
the library provides an ADT
to describe each use case and the location for additional claims.
There is extension methods created for convenience import io.oath.syntax.*
then you should be able to convert Any
to a JwtClaims
.
sealed trait JwtClaims
object JwtClaims {
final case class Claims(registered: RegisteredClaims = RegisteredClaims.empty) extends JwtClaims
final case class ClaimsH[+H](header: H, registered: RegisteredClaims = RegisteredClaims.empty) extends JwtClaims
final case class ClaimsP[+P](payload: P, registered: RegisteredClaims = RegisteredClaims.empty) extends JwtClaims
final case class ClaimsHP[+H, +P](header: H, payload: P, registered: RegisteredClaims = RegisteredClaims.empty) extends JwtClaims
}
The JWT (JSON Web Token) is described as a whole with the claims
& token
in
the below data structure. The token
is in this form base64(header).base64(payload).signature
.
final case class Jwt[+C <: JwtClaims](claims: C, token: String)
Use only for issuing JWT Tokens. For asymmetric algorithms only private-key is required, see configuration.
import io.circe.generic.auto.*
import io.oath.syntax.*
import io.oath.circe.derive.*
final case class Foo(name: String, age: Int)
val config = IssuerConfig.loadOrThrow("token") // HMAC256 with "secret" as secret
val issuer = new JwtIssuer(config)
val foo = Foo("foo", 10)
val maybeJwt: Either[IssueJwtError, Jwt[JwtClaims.ClaimsP[Foo]]] = issuer.issueJwt(foo.toClaimsP)
// Right(Jwt(ClaimsP(Foo(foo,10),RegisteredClaims(None,None,List(),None,None,None,None)),eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJuYW1lIjoiZm9vIiwiYWdlIjoxMH0.oeU3zySKPA-fowGQkl0WPDwyBhXJUEtobSjGQsDBXcs))
JwtVerifier Overview
Use only for verifying JWT Tokens. For asymmetric algorithms only public-key is required,
see configuration.
In order for the verifier API to determine the location of the data in the token, the verifyJwt
function takes
a JwtToken
.
There is extension methods created for convenience import io.oath.syntax.*
then you should be able to convert any
string to a JwtToken
.
sealed trait JwtToken {
def token: String
}
object JwtToken {
final case class Token(token: String) extends JwtToken // From registered claims
final case class TokenH(token: String) extends JwtToken // From registered claims + header
final case class TokenP(token: String) extends JwtToken // From registered claims + payload
final case class TokenHP(token: String) extends JwtToken // From registered claims + header + payload
}
import io.circe.generic.auto.*
import io.oath.syntax.*
import io.oath.circe.derive.*
final case class Foo(name: String, age: Int)
val config = VerifierConfig.loadOrThrow("token") // HMAC256 with "secret" as secret
val verifier = new JwtVerifier(config)
val token = "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJuYW1lIjoiZm9vIiwiYWdlIjoxMH0.oeU3zySKPA-fowGQkl0WPDwyBhXJUEtobSjGQsDBXcs"
val claims: Either[JwtVerifyError, JwtClaims.ClaimsP[Foo]] = verifier.verifyJwt[Foo](token.toTokenP)
// Right(ClaimsP(Foo(foo,10),RegisteredClaims(None,None,List(),None,None,None,None)))
JwtManager Overview
Used for verifying and issuing JWT Tokens, see configuration.
import io.circe.generic.auto.*
import eu.timepit.refined.auto.*
import io.oath.syntax.*
import io.oath.circe.derive.*
final case class Foo(name: String, age: Int)
val config = ManagerConfig.loadOrThrow("token")
val manager = new JwtManager(config)
val foo = Foo("foo", 10)
val jwt: Jwt[JwtClaims.ClaimsP[Foo]] = manager.issueJwt(foo.toClaimsP).toOption.get
val claims: JwtClaims.ClaimsP[Foo] = manager.verifyJwt[Foo](jwt.token.toTokenP).toOption.get
Advanced Encryption Standard (AES)
Sensitive data in JWT Tokens might lead to an exposure of unwanted information (User data, Internal technologies, etc.).
It's recommended to encrypt the data when is possible on the client side to prevent data leaks and been exposed to attacks.
To enable encryption you must provide a secret
key to the configuration file.
encrypt {
secret = "password"
}
Ad-hoc Registered Claims
The library also provides ad-hoc claims manipulation with priority to the claims that have been provided by the code.
token {
algorithm {
name = "HMAC256"
secret = "secret"
}
issuer {
registered {
issuer-claim = "issuer"
subject-claim = "subject"
}
}
}
import io.circe.generic.auto.*
import io.oath.model.*
import io.oath.syntax.*
import io.oath.circe.derive.*
final case class Foo(name: String, age: Int)
val config = IssuerConfig.loadOrThrow("token")
val issuer = new JwtIssuer(config)
val foo = Foo("foo", 10)
val adHocClaimsP = JwtClaims.ClaimsP(foo, RegisteredClaims.empty.copy(iss = Some("foo")))
val maybeJwt: Either[IssueJwtError, Jwt[JwtClaims.ClaimsP[Foo]]] = issuer.issueJwt(adHocClaimsP)
// Right(Jwt(ClaimsP(Foo(foo,10),RegisteredClaims(Some(foo),Some(subject),List(),None,None,None,None)),eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJuYW1lIjoiZm9vIiwiaXNzIjoiaXNzdWVyIiwiYWdlIjoxMH0.Dlow6pYmJ-5STSuEzL3WYnjpCrGYMKzadIwlOK_WBBc))
Oath Example
As described above we have 3 main components JwtIssuer
, JwtVerifier
and JwtManager
.
The bellow example will demonstrate how to create one of this components for multiple tokens with different configuration using scala ADT
.
OathEnumEntry && OathEnum
Those traits are necessary to retrieve the
names for each Enum
custom value on compile time using macros.
trait OathEnumEntry
trait OathEnum[A <: OathEnumEntry]
The Enum
token names will be converted by default from UPPER_CAMEL => LOWER_HYPHEN
which is
going to be the name that the library is going to search in your local config file.
sealed trait OathExampleToken extends OathEnumEntry
object OathExampleToken extends OathEnum[OathExampleToken] {
case object AccessToken extends OathExampleToken // name in config access-token
case object RefreshToken extends OathExampleToken // refresh-token
case object ActivationEmailToken extends OathExampleToken // activation-email-token
case object ForgotPasswordToken extends OathExampleToken // forgot-password-token
override val tokenValues: Set[OathExampleToken] = findTokenEnumMembers
// Use OathIssuer or OathVerifier to construct JwtIssuer's or JwtVerifier's
val oathManager: OathManager[OathExampleToken] = OathManager.createOrFail(OathExampleToken)
val AccessTokenManager: JwtManager[AccessToken.type] = oathManager.as(AccessToken)
val RefreshTokenManager: JwtManager[RefreshToken.type] = oathManager.as(RefreshToken)
val ActivationEmailTokenManager: JwtManager[ActivationEmailToken.type] = oathManager.as(ActivationEmailToken)
val ForgotPasswordTokenManager: JwtManager[ForgotPasswordToken.type] = oathManager.as(ForgotPasswordToken)
}
OR you can override a configName with:
sealed trait OathExampleToken extends OathEnumEntry
object OathExampleToken extends OathEnum[OathExampleToken] {
case object AccessToken extends OathExampleToken {
override val configName: String = "access-session-token" // name in config access-session-token
}
...
}
OR you can override all configNames with:
sealed abstract class OathExampleToken(override val configName: String) extends OathEnumEntry
object OathExampleToken extends OathEnum[OathExampleToken] {
case object AccessToken extends OathExampleToken("access-session-token") // name in config access-session-token
...
}
Configuration
token.algorithm:
Key | Type | Description | Required |
---|---|---|---|
token.algorithm.name | String | The Algorithm name (HMAC256, RSA256, etc.) | ✅ |
token.algorithm.private-key-pem-path | String | Private key pem file path | ✅ Only for asymmetric algorithms and issuing tokens |
token.algorithm.public-key-pem-path | String | Public key pem file path | ✅ Only for asymmetric algorithms and verifying tokens |
token.algorithm.secret | String | Secret signing key | ✅ Only for symmetric algorithms |
token.encrypt:
Key | Type | Description | Required |
---|---|---|---|
token.encrypt.secret | String | Secret encryption key | ❎ |
token.issuer:
Key | Type | Description | Required | Default |
---|---|---|---|---|
token.issuer.registered.issuer-claim | String | iss claim value | ❎ | Null |
token.issuer.registered.subject-claim | String | sub claim value | ❎ | Null |
token.issuer.registered.audience-claims | List[String] | aud claim values | ❎ | Null |
token.issuer.registered.include-issued-at-claim | Boolean | iat claim auto-generated value | ❎ | false |
token.issuer.registered.include-jwt-id-claim | Boolean | jti claim auto-generated value | ❎ | false |
token.issuer.registered.expires-at-offset | Duration | exp claim adjust time with offset provided | ❎ | Null |
token.issuer.registered.not-before-offset | Duration | nbf claim adjust time with offset provided | ❎ | Null |
token.verifier:
Key | Type | Description | Required | Default |
---|---|---|---|---|
token.verifier.provided-with.issuer-claim | String | Verify iss claim contains the exact value | ❎ | Null |
token.verifier.provided-with.subject-claim | String | Verify sub claim contains the exact value | ❎ | Null |
token.verifier.provided-with.audience-claims | List[String] | Verify aud claim contains the exact values | ❎ | Null |
token.verifier.leeway-window.leeway | Duration | Leeway window allow late JWTs with offset, checks [exp , nbf , iat ] | ❎ | Null |
token.verifier.leeway-window.issued-at | Duration | Leeway window allow late JWTs with offset, checks [iat ] | ❎ | Null |
token.verifier.leeway-window.expires-at | Duration | Leeway window allow late JWTs with offset, checks [exp ] | ❎ | Null |
token.verifier.leeway-window.not-before | Duration | Leeway window allow late JWTs with offset, checks [nbf ] | ❎ | Null |
Issuer Sample
token {
algorithm {
name = "RS256"
private-key-pem-path = "src/test/secrets/rsa-private.pem"
}
// algorithm {
// name = "HMAC256"
// secret = "secret" When using HMAC single secret is required for both verifier and issuer
// }
encrypt {
secret = "password"
}
issuer {
registered {
issuer-claim = "issuer"
subject-claim = "subject"
audience-claims = ["aud1", "aud2"]
include-issued-at-claim = true
include-jwt-id-claim = false
expires-at-offset = 1 day
not-before-offset = 1 minute
}
}
}
Verifier Sample
token {
algorithm {
name = "RS256"
public-key-pem-path = "src/test/secrets/rsa-public.pem"
}
// algorithm {
// name = "HMAC256"
// secret = "secret" When using HMAC single secret is required for both verifier and issuer
// }
encrypt {
secret = "password"
}
verifier {
provided-with {
issuer-claim = "issuer"
subject-claim = "subject"
audience-claims = []
}
leeway-window {
issued-at = 4 minutes
expires-at = 3 minutes
not-before = 2 minutes
}
}
}
Manager Sample
token {
algorithm {
name = "RS256"
private-key-pem-path = "src/test/secrets/rsa-private.pem"
public-key-pem-path = "src/test/secrets/rsa-public.pem"
}
// algorithm {
// name = "HMAC256"
// secret = "secret" When using HMAC single secret is required for both verifier and issuer
// }
encrypt {
secret = "password"
}
issuer {
registered {
issuer-claim = "issuer"
subject-claim = "subject"
audience-claims = ["aud1", "aud2"]
include-issued-at-claim = true
include-jwt-id-claim = false
expires-at-offset = 1 day
not-before-offset = 1 minute
}
}
verifier {
provided-with {
issuer-claim = ${token.issuer.registered.issuer-claim}
subject-claim = ${token.issuer.registered.subject-claim}
audience-claims = ${token.issuer.registered.audience-claims}
}
leeway-window {
issued-at = 4 minutes
expires-at = 3 minutes
not-before = 2 minutes
}
}
}
Oath Sample
oath {
access-token {
algorithm {
name = "HS256"
secret-key = "secret"
}
issuer {
registered {
issuer-claim = "access-token"
subject-claim = "subject"
audience-claims = ["aud1", "aud2"]
include-issued-at-claim = true
include-jwt-id-claim = true
expires-at-offset = 15 minutes
not-before-offset = 0 minute
}
}
verifier {
provided-with {
issuer-claim = ${oath.access-token.issuer.registered.issuer-claim}
subject-claim = ${oath.access-token.issuer.registered.subject-claim}
audience-claims = ${oath.access-token.issuer.registered.audience-claims}
}
leeway-window {
leeway = 1 minute
issued-at = 1 minute
expires-at = 1 minute
not-before = 1 minute
}
}
}
refresh-token = ${oath.access-token}
refresh-token {
issuer {
registered {
issuer-claim = "refresh-token"
expires-at-offset = 6 hours
}
}
verifier {
provided-with {
issuer-claim = ${oath.refresh-token.issuer.registered.issuer-claim}
}
}
}
activation-email-token = ${oath.access-token}
activation-email-token {
issuer {
registered {
issuer-claim = "activation-email-token"
expires-at-offset = 1 day
audience-claims = []
}
}
verifier {
provided-with {
issuer-claim = ${oath.activation-email-token.issuer.registered.issuer-claim}
audience-claims = []
}
}
}
forgot-password-token = ${oath.access-token}
forgot-password-token {
issuer {
registered {
issuer-claim = "forgot-password-token"
expires-at-offset = 2 hours
audience-claims = []
}
}
verifier {
provided-with {
issuer-claim = ${oath.forgot-password-token.issuer.registered.issuer-claim}
audience-claims = []
}
}
}
}