Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

117 delay option for new set of key signing #118

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 29 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,9 @@ AbsaOSS Common Login service using JWT Public key signatures
To interact with the service, most notable endpoints are
- `/token/generate` to generate access & refresh tokens
- `/token/refresh` to obtain a new access token with a still-valid refresh token
- `/token/public-key` to obtain public key to verify tokens including their validity window
- `/token/public-key` to obtain the currently signing public key to verify tokens including their validity window
- `/token/public-keys` to obtain all available public keys including the current and previously rotated keys.
- `/token/public-key-jwks` gives same data as `/token/public-keys` but in the form of a JSON Web Key Set.

Please, refer to the [API documentation](#api-documentation) below for details of the endpoints.

Expand Down Expand Up @@ -201,14 +203,28 @@ loginsvc:
access-exp-time: 15min
refresh-exp-time: 9h
key-rotation-time: 9h
key-lay-over-time: 15min
key-phase-out-time: 30min
alg-name: "RS256"
```
There are a few important configuration values to be provided:
- `access-exp-time` which indicates how long an access token is valid for,
- `refresh-exp-time` which indicates how long a refresh token is valid for,
- Optional property: `key-rotation-time` which indicates how often Key pairs are rotated. Rotation will be disabled if missing.
- Optional property: `key-lay-over-time` which indicates a delay after rotation before using the newly created key for signing. Lay-over will be disabled if missing.
- Optional property: `key-phase-out-time` which indicates the time to phase out the older key. Timer is scheduled after `key-lay-over-time` if enabled. Phase-out will be disabled if missing.
- `alg-name` which indicates which algorithm is used to encode your keys.

Using the above values, the optional properties will give the following effect after the 1st rotation at 9 hours:
```
t=0: keys rotation happens
t=0-14m: layover period: old key from before rotation is still used for signing. Both public keys available from public-keys endpoint.
t=15-44m: layover is over: new key from after rotation is used for signing. Both public keys available from public-keys endpoint.
t=45m+: phase-out happens: new key from after rotation is used for signing. Old Key is no longer available from public-keys endpoint.
```
These properties cannot be enabled if rotation is not enabled. The combined values of these properties cannot be higher than the rotation time.


To setup for AWS Secrets Manager, your config should look like so:
```
loginsvc:
Expand All @@ -222,6 +238,8 @@ loginsvc:
access-exp-time: 15min
refresh-exp-time: 9h
poll-time: 30min
key-lay-over-time: 15min
key-phase-out-time: 30min
alg-name: "RS256"
```
Your AWS Secret must have at least 2 fields which correspond to the above properties:
Expand All @@ -236,7 +254,17 @@ There are a few important configuration values to be provided:
- `access-exp-time` which indicates how long an access token is valid for,
- `refresh-exp-time` which indicates how long a refresh token is valid for,
- Optional property:`poll-time` which indicates how often key pairs (`private-key-field-name` and `public-key-field-name`) are polled and fetched from AWS Secrets Manager. Polling will be disabled if missing.
- Optional property: `key-lay-over-time` which indicates a delay after rotation before using the newly created key for signing. Lay-over will be disabled if missing.
- Optional property: `key-phase-out-time` which indicates the time to phase out the older key. Timer is scheduled after `key-lay-over-time` if enabled. Phase-out will be disabled if missing.
- `alg-name` which indicates which algorithm is used to encode your keys.
Using the above values, the optional properties will give the following effect after the 1st rotation at 9 hours:
```
t=0: keys rotation happens
t=0-14m: layover period: old key from before rotation is still used for signing. Both public keys available from public-keys endpoint.
t=15-44m: layover is over: new key from after rotation is used for signing. Both public keys available from public-keys endpoint.
t=45m+: phase-out happens: new key from after rotation is used for signing. Old Key is no longer available from public-keys endpoint.
```
These properties cannot be enabled if polling is not enabled.

Please note that only one configuration option (`loginsvc.rest.jwt.{aws-secrets-manager|generate-in-memory}`) can be used at a time.

Expand Down
6 changes: 4 additions & 2 deletions api/src/main/resources/example.application.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@ loginsvc:
access-exp-time: 15min
refresh-exp-time: 9h
key-rotation-time: 9h
key-phase-out-time: 30min
key-lay-over-time: 15min
key-phase-out-time: 15min
alg-name: "RS256"
#Instead of generating the key in memory
#The Below Config allows for the application to fetch keys from AWS Secrets Manager.
Expand All @@ -19,7 +20,8 @@ loginsvc:
#access-exp-time: 15min
#refresh-exp-time: 9h
#poll-time: 5min
#key-phase-out-time: 30min
#key-lay-over-time: 15min
#key-phase-out-time: 15min
#alg-name: "RS256"
config:
# Generates git.properties file for use on info endpoint.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,13 +19,13 @@ package za.co.absa.loginsvc.rest.config.jwt
import org.slf4j.LoggerFactory
import za.co.absa.loginsvc.rest.config.validation.{ConfigValidationException, ConfigValidationResult}
import za.co.absa.loginsvc.rest.config.validation.ConfigValidationResult.{ConfigValidationError, ConfigValidationSuccess}
import za.co.absa.loginsvc.utils.AwsSecretsUtils
import za.co.absa.loginsvc.utils.{AwsSecretsUtils, SecretUtils}

import java.security.{KeyFactory, KeyPair}
import java.security.spec.{PKCS8EncodedKeySpec, X509EncodedKeySpec}
import java.time.Instant
import java.util.Base64
import scala.concurrent.duration.FiniteDuration
import scala.concurrent.duration.{Duration, FiniteDuration}

case class AwsSecretsManagerKeyConfig(
secretName: String,
Expand All @@ -36,18 +36,58 @@ case class AwsSecretsManagerKeyConfig(
accessExpTime: FiniteDuration,
refreshExpTime: FiniteDuration,
pollTime: Option[FiniteDuration],
keyLayOverTime: Option[FiniteDuration],
keyPhaseOutTime: Option[FiniteDuration]
) extends KeyConfig {

private val logger = LoggerFactory.getLogger(classOf[AwsSecretsManagerKeyConfig])

override def keyRotationTime : Option[FiniteDuration] = pollTime
override def keyPair(): (KeyPair, Option[KeyPair]) = {
override def keyPair(): (KeyPair, Option[KeyPair]) = fetchKeySetsFromCloud()

override def throwErrors(): Unit = this.validate().throwOnErrors()

override def validate(): ConfigValidationResult = {

val awsSecretsResults = Seq(
Option(secretName)
.map(_ => ConfigValidationSuccess)
.getOrElse(ConfigValidationError(ConfigValidationException("secretName is empty"))),

Option(region)
.map(_ => ConfigValidationSuccess)
.getOrElse(ConfigValidationError(ConfigValidationException("region is empty"))),

Option(privateKeyFieldName)
.map(_ => ConfigValidationSuccess)
.getOrElse(ConfigValidationError(ConfigValidationException("privateKeyFieldName is empty"))),

Option(publicKeyFieldName)
.map(_ => ConfigValidationSuccess)
.getOrElse(ConfigValidationError(ConfigValidationException("publicKeyFieldName is empty"))),
)

val awsSecretsResultsMerge = awsSecretsResults.foldLeft[ConfigValidationResult](ConfigValidationSuccess)(ConfigValidationResult.merge)

super.validate().merge(awsSecretsResultsMerge)
}

/**
* Fetches the keypair used for generating Java Web Tokens from Cloud.
* Fetches both the current as well as previously rotated keys if available.
*
* @param secretsUtils The methods used to fetch the keys.
* Mainly used for testing and can be left empty to use the default value in standard use.
* @return A tuple of the most current KeyPair as well as an option of the previously rotated keypair if available.
* The order and availability of the keys are dependant on key-lay-over and key-phase-out if enabled.
*/
private[jwt] def fetchKeySetsFromCloud(secretsUtils: SecretUtils = AwsSecretsUtils): (KeyPair, Option[KeyPair]) = {
try {
val currentSecretsOption = AwsSecretsUtils.fetchSecret(
val currentSecretsOption = secretsUtils.fetchSecret(
secretName,
region,
Array(privateKeyFieldName, publicKeyFieldName)
Array(privateKeyFieldName, publicKeyFieldName),
None
)

if(currentSecretsOption.isEmpty)
Expand All @@ -58,19 +98,20 @@ case class AwsSecretsManagerKeyConfig(
logger.info("AWSCURRENT Key Data successfully retrieved and parsed from AWS Secrets Manager")

val previousSecretsOption =
AwsSecretsUtils.fetchSecret(
secretName,
region,
Array(privateKeyFieldName, publicKeyFieldName),
Some("AWSPREVIOUS")
)
secretsUtils.fetchSecret(
secretName,
region,
Array(privateKeyFieldName, publicKeyFieldName),
Some("AWSPREVIOUS")
)

val previousKeyPair = previousSecretsOption.flatMap { previousSecrets =>
try {
val keys = createKeyPair(previousSecrets.secretValue)
logger.info("AWSPREVIOUS Key Data successfully retrieved and parsed from AWS Secrets Manager")
val exp = keyPhaseOutTime.exists(isExpired(currentSecrets.createTime, _))
if(exp) { None }
val keyPhaseOutActive = keyPhaseOutTime.exists(kpot =>
isExpired(currentSecrets.createTime, kpot + keyLayOverTime.getOrElse(Duration.Zero)))
if(keyPhaseOutActive) { None }
else { Some(keys) }
} catch {
case e: Throwable =>
Expand All @@ -79,41 +120,22 @@ case class AwsSecretsManagerKeyConfig(
}
}

(currentKeyPair, previousKeyPair)
previousKeyPair.fold {(currentKeyPair, previousKeyPair)} { pk =>
val keyLayOverActive = keyLayOverTime.exists(!isExpired(currentSecrets.createTime, _))
if (!keyLayOverActive) {
(currentKeyPair, previousKeyPair)
}
else {
(pk, Some(currentKeyPair))
}
}
} catch {
case e: Throwable =>
logger.error(s"Error occurred retrieving and decoding keys from AWS Secrets Manager", e)
throw e
}
}

override def throwErrors(): Unit = this.validate().throwOnErrors()

override def validate(): ConfigValidationResult = {

val awsSecretsResults = Seq(
Option(secretName)
.map(_ => ConfigValidationSuccess)
.getOrElse(ConfigValidationError(ConfigValidationException("secretName is empty"))),

Option(region)
.map(_ => ConfigValidationSuccess)
.getOrElse(ConfigValidationError(ConfigValidationException("region is empty"))),

Option(privateKeyFieldName)
.map(_ => ConfigValidationSuccess)
.getOrElse(ConfigValidationError(ConfigValidationException("privateKeyFieldName is empty"))),

Option(publicKeyFieldName)
.map(_ => ConfigValidationSuccess)
.getOrElse(ConfigValidationError(ConfigValidationException("publicKeyFieldName is empty"))),
)

val awsSecretsResultsMerge = awsSecretsResults.foldLeft[ConfigValidationResult](ConfigValidationSuccess)(ConfigValidationResult.merge)

super.validate().merge(awsSecretsResultsMerge)
}

private def createKeyPair(secretKeys: Map[String, String]): KeyPair = {

val publicKeySpec: X509EncodedKeySpec = new X509EncodedKeySpec(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,13 +23,14 @@ import za.co.absa.loginsvc.rest.config.validation.{ConfigValidationException, Co
import za.co.absa.loginsvc.rest.config.validation.ConfigValidationResult.{ConfigValidationError, ConfigValidationSuccess}

import java.security.KeyPair
import scala.concurrent.duration.FiniteDuration
import scala.concurrent.duration.{Duration, FiniteDuration}

case class InMemoryKeyConfig(
algName: String,
accessExpTime: FiniteDuration,
refreshExpTime: FiniteDuration,
keyRotationTime: Option[FiniteDuration],
keyLayOverTime: Option[FiniteDuration],
keyPhaseOutTime: Option[FiniteDuration]
) extends KeyConfig {

Expand All @@ -45,12 +46,13 @@ case class InMemoryKeyConfig(
}

override def validate(): ConfigValidationResult = {
val keyPhaseOutTimeResult = if(keyPhaseOutTime.nonEmpty && keyRotationTime.nonEmpty
&& keyPhaseOutTime.get > keyRotationTime.get) {
ConfigValidationError(ConfigValidationException(s"keyPhaseOutTime must be lower than keyRotationTime!"))
val optionalKeyTimeResult = if(keyRotationTime.nonEmpty
&& (keyPhaseOutTime.getOrElse(Duration.Zero) + keyLayOverTime.getOrElse(Duration.Zero)) > keyRotationTime.get) {
ConfigValidationError(ConfigValidationException(
s"keyLayOverTime + keyPhaseOutTime must be lower than keyRotationTime!"))
} else ConfigValidationSuccess

super.validate().merge(keyPhaseOutTimeResult)
super.validate().merge(optionalKeyTimeResult)
}

override def throwErrors(): Unit = this.validate().throwOnErrors()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ trait KeyConfig extends ConfigValidatable {
def accessExpTime: FiniteDuration
def refreshExpTime: FiniteDuration
def keyRotationTime: Option[FiniteDuration]
def keyLayOverTime: Option[FiniteDuration]
def keyPhaseOutTime: Option[FiniteDuration]
def keyPair(): (KeyPair, Option[KeyPair])
def throwErrors(): Unit
Expand Down Expand Up @@ -79,6 +80,14 @@ trait KeyConfig extends ConfigValidatable {
ConfigValidationError(ConfigValidationException(s"keyPhaseOutTime can only be enable if keyRotationTime is enable!"))
} else ConfigValidationSuccess

val keyLayoverTimeResult = if (keyLayOverTime.nonEmpty && keyLayOverTime.get < KeyConfig.minKeyLayOverTime) {
ConfigValidationError(ConfigValidationException(s"keyLayOverTime must be at least ${KeyConfig.minKeyLayOverTime}"))
} else ConfigValidationSuccess

val keyLayOverWithRotationResult = if (keyLayOverTime.nonEmpty && keyRotationTime.isEmpty) {
ConfigValidationError(ConfigValidationException(s"keyLayOverTime can only be enable if keyRotationTime is enable!"))
} else ConfigValidationSuccess

if (keyRotationTime.isEmpty) {
logger.warn("keyRotationTime is not set in config, key-pair will not be rotated!")
}
Expand All @@ -93,6 +102,8 @@ trait KeyConfig extends ConfigValidatable {
.merge(keyRotationTimeResult)
.merge(keyPhaseOutTimeResult)
.merge(keyPhaseOutWithRotationResult)
.merge(keyLayoverTimeResult)
.merge(keyLayOverWithRotationResult)
}
}

Expand All @@ -101,4 +112,5 @@ object KeyConfig {
val minRefreshExpTime: FiniteDuration = 10.milliseconds
val minKeyRotationTime: FiniteDuration = 10.milliseconds
val minKeyPhaseOutTime: FiniteDuration = 10.milliseconds
val minKeyLayOverTime: FiniteDuration = 10.milliseconds
}
Loading
Loading