Home

Awesome

Build status CI Status Coverage status Maven Central

OATH - Scala JWT

OATH provides an easy way for Rest API Applications to manipulate JWTs securely in complex systems.

  1. Customize registered claims via configuration.
  2. Create a variety of JWT tokens with different configuration for each use case
  3. 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

JWSAlgorithmDescription
HS256HMAC256HMAC with SHA-256
HS384HMAC384HMAC with SHA-384
HS512HMAC512HMAC with SHA-512
RS256RSA256RSASSA-PKCS1-v1_5 with SHA-256
RS384RSA384RSASSA-PKCS1-v1_5 with SHA-384
RS512RSA512RSASSA-PKCS1-v1_5 with SHA-512
ES256ECDSA256ECDSA with curve P-256 and SHA-256
ES384ECDSA384ECDSA with curve P-384 and SHA-384
ES512ECDSA512ECDSA 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:

KeyTypeDescriptionRequired
token.algorithm.nameStringThe Algorithm name (HMAC256, RSA256, etc.)
token.algorithm.private-key-pem-pathStringPrivate key pem file path✅ Only for asymmetric algorithms and issuing tokens
token.algorithm.public-key-pem-pathStringPublic key pem file path✅ Only for asymmetric algorithms and verifying tokens
token.algorithm.secretStringSecret signing key✅ Only for symmetric algorithms

token.encrypt:

KeyTypeDescriptionRequired
token.encrypt.secretStringSecret encryption key

token.issuer:

KeyTypeDescriptionRequiredDefault
token.issuer.registered.issuer-claimStringiss claim valueNull
token.issuer.registered.subject-claimStringsub claim valueNull
token.issuer.registered.audience-claimsList[String]aud claim valuesNull
token.issuer.registered.include-issued-at-claimBooleaniat claim auto-generated valuefalse
token.issuer.registered.include-jwt-id-claimBooleanjti claim auto-generated valuefalse
token.issuer.registered.expires-at-offsetDurationexp claim adjust time with offset providedNull
token.issuer.registered.not-before-offsetDurationnbf claim adjust time with offset providedNull

token.verifier:

KeyTypeDescriptionRequiredDefault
token.verifier.provided-with.issuer-claimStringVerify iss claim contains the exact valueNull
token.verifier.provided-with.subject-claimStringVerify sub claim contains the exact valueNull
token.verifier.provided-with.audience-claimsList[String]Verify aud claim contains the exact valuesNull
token.verifier.leeway-window.leewayDurationLeeway window allow late JWTs with offset, checks [exp, nbf, iat]Null
token.verifier.leeway-window.issued-atDurationLeeway window allow late JWTs with offset, checks [iat]Null
token.verifier.leeway-window.expires-atDurationLeeway window allow late JWTs with offset, checks [exp]Null
token.verifier.leeway-window.not-beforeDurationLeeway 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 = []
      }
    }
  }
}