Skip to content

Commit f278e13

Browse files
authored
117 delay option for new set of key signing (#118)
* Add KeyLayOver for inMemory use * Implement layOver for AWS SecretsManager uses * Add Tests to InMemoryKeyConfigTest.scala * Add Tests to AwsSecretsManagerKeyConfigTest * Add Tests to JWTServiceTest * Update refreshTokens to use both keys * Edit Logging * Minor Change on example config * Fix Logic on KeyLayover in AwsSecretsManagerKeyConfig * Use A combination of KeyPhaseOutTime and KeyLayover time to set schedules * Change order of parameters * Refactor AwsSecretsManagerKeyConfig to accomodate testing keyPair * Add Tests for fetchKeySetsFromCloud * Updated README.md * Updated README.md - part 2 * Minor Refactoring for readability * Minor Refactoring for readability - part 2
1 parent e5a3364 commit f278e13

File tree

12 files changed

+380
-90
lines changed

12 files changed

+380
-90
lines changed

README.md

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,9 @@ AbsaOSS Common Login service using JWT Public key signatures
88
To interact with the service, most notable endpoints are
99
- `/token/generate` to generate access & refresh tokens
1010
- `/token/refresh` to obtain a new access token with a still-valid refresh token
11-
- `/token/public-key` to obtain public key to verify tokens including their validity window
11+
- `/token/public-key` to obtain the currently signing public key to verify tokens including their validity window
12+
- `/token/public-keys` to obtain all available public keys including the current and previously rotated keys.
13+
- `/token/public-key-jwks` gives same data as `/token/public-keys` but in the form of a JSON Web Key Set.
1214

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

@@ -201,14 +203,28 @@ loginsvc:
201203
access-exp-time: 15min
202204
refresh-exp-time: 9h
203205
key-rotation-time: 9h
206+
key-lay-over-time: 15min
207+
key-phase-out-time: 30min
204208
alg-name: "RS256"
205209
```
206210
There are a few important configuration values to be provided:
207211
- `access-exp-time` which indicates how long an access token is valid for,
208212
- `refresh-exp-time` which indicates how long a refresh token is valid for,
209213
- Optional property: `key-rotation-time` which indicates how often Key pairs are rotated. Rotation will be disabled if missing.
214+
- 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.
215+
- 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.
210216
- `alg-name` which indicates which algorithm is used to encode your keys.
211217

218+
Using the above values, the optional properties will give the following effect after the 1st rotation at 9 hours:
219+
```
220+
t=0: keys rotation happens
221+
t=0-14m: layover period: old key from before rotation is still used for signing. Both public keys available from public-keys endpoint.
222+
t=15-44m: layover is over: new key from after rotation is used for signing. Both public keys available from public-keys endpoint.
223+
t=45m+: phase-out happens: new key from after rotation is used for signing. Old Key is no longer available from public-keys endpoint.
224+
```
225+
These properties cannot be enabled if rotation is not enabled. The combined values of these properties cannot be higher than the rotation time.
226+
227+
212228
To setup for AWS Secrets Manager, your config should look like so:
213229
```
214230
loginsvc:
@@ -222,6 +238,8 @@ loginsvc:
222238
access-exp-time: 15min
223239
refresh-exp-time: 9h
224240
poll-time: 30min
241+
key-lay-over-time: 15min
242+
key-phase-out-time: 30min
225243
alg-name: "RS256"
226244
```
227245
Your AWS Secret must have at least 2 fields which correspond to the above properties:
@@ -236,7 +254,17 @@ There are a few important configuration values to be provided:
236254
- `access-exp-time` which indicates how long an access token is valid for,
237255
- `refresh-exp-time` which indicates how long a refresh token is valid for,
238256
- 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.
257+
- 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.
258+
- 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.
239259
- `alg-name` which indicates which algorithm is used to encode your keys.
260+
Using the above values, the optional properties will give the following effect after the 1st rotation at 9 hours:
261+
```
262+
t=0: keys rotation happens
263+
t=0-14m: layover period: old key from before rotation is still used for signing. Both public keys available from public-keys endpoint.
264+
t=15-44m: layover is over: new key from after rotation is used for signing. Both public keys available from public-keys endpoint.
265+
t=45m+: phase-out happens: new key from after rotation is used for signing. Old Key is no longer available from public-keys endpoint.
266+
```
267+
These properties cannot be enabled if polling is not enabled.
240268

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

api/src/main/resources/example.application.yaml

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,8 @@ loginsvc:
77
access-exp-time: 15min
88
refresh-exp-time: 9h
99
key-rotation-time: 9h
10-
key-phase-out-time: 30min
10+
key-lay-over-time: 15min
11+
key-phase-out-time: 15min
1112
alg-name: "RS256"
1213
#Instead of generating the key in memory
1314
#The Below Config allows for the application to fetch keys from AWS Secrets Manager.
@@ -19,7 +20,8 @@ loginsvc:
1920
#access-exp-time: 15min
2021
#refresh-exp-time: 9h
2122
#poll-time: 5min
22-
#key-phase-out-time: 30min
23+
#key-lay-over-time: 15min
24+
#key-phase-out-time: 15min
2325
#alg-name: "RS256"
2426
config:
2527
# Generates git.properties file for use on info endpoint.

api/src/main/scala/za/co/absa/loginsvc/rest/config/jwt/AwsSecretsManagerKeyConfig.scala

Lines changed: 63 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -19,13 +19,13 @@ package za.co.absa.loginsvc.rest.config.jwt
1919
import org.slf4j.LoggerFactory
2020
import za.co.absa.loginsvc.rest.config.validation.{ConfigValidationException, ConfigValidationResult}
2121
import za.co.absa.loginsvc.rest.config.validation.ConfigValidationResult.{ConfigValidationError, ConfigValidationSuccess}
22-
import za.co.absa.loginsvc.utils.AwsSecretsUtils
22+
import za.co.absa.loginsvc.utils.{AwsSecretsUtils, SecretUtils}
2323

2424
import java.security.{KeyFactory, KeyPair}
2525
import java.security.spec.{PKCS8EncodedKeySpec, X509EncodedKeySpec}
2626
import java.time.Instant
2727
import java.util.Base64
28-
import scala.concurrent.duration.FiniteDuration
28+
import scala.concurrent.duration.{Duration, FiniteDuration}
2929

3030
case class AwsSecretsManagerKeyConfig(
3131
secretName: String,
@@ -36,18 +36,58 @@ case class AwsSecretsManagerKeyConfig(
3636
accessExpTime: FiniteDuration,
3737
refreshExpTime: FiniteDuration,
3838
pollTime: Option[FiniteDuration],
39+
keyLayOverTime: Option[FiniteDuration],
3940
keyPhaseOutTime: Option[FiniteDuration]
4041
) extends KeyConfig {
4142

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

4445
override def keyRotationTime : Option[FiniteDuration] = pollTime
45-
override def keyPair(): (KeyPair, Option[KeyPair]) = {
46+
override def keyPair(): (KeyPair, Option[KeyPair]) = fetchKeySetsFromCloud()
47+
48+
override def throwErrors(): Unit = this.validate().throwOnErrors()
49+
50+
override def validate(): ConfigValidationResult = {
51+
52+
val awsSecretsResults = Seq(
53+
Option(secretName)
54+
.map(_ => ConfigValidationSuccess)
55+
.getOrElse(ConfigValidationError(ConfigValidationException("secretName is empty"))),
56+
57+
Option(region)
58+
.map(_ => ConfigValidationSuccess)
59+
.getOrElse(ConfigValidationError(ConfigValidationException("region is empty"))),
60+
61+
Option(privateKeyFieldName)
62+
.map(_ => ConfigValidationSuccess)
63+
.getOrElse(ConfigValidationError(ConfigValidationException("privateKeyFieldName is empty"))),
64+
65+
Option(publicKeyFieldName)
66+
.map(_ => ConfigValidationSuccess)
67+
.getOrElse(ConfigValidationError(ConfigValidationException("publicKeyFieldName is empty"))),
68+
)
69+
70+
val awsSecretsResultsMerge = awsSecretsResults.foldLeft[ConfigValidationResult](ConfigValidationSuccess)(ConfigValidationResult.merge)
71+
72+
super.validate().merge(awsSecretsResultsMerge)
73+
}
74+
75+
/**
76+
* Fetches the keypair used for generating Java Web Tokens from Cloud.
77+
* Fetches both the current as well as previously rotated keys if available.
78+
*
79+
* @param secretsUtils The methods used to fetch the keys.
80+
* Mainly used for testing and can be left empty to use the default value in standard use.
81+
* @return A tuple of the most current KeyPair as well as an option of the previously rotated keypair if available.
82+
* The order and availability of the keys are dependant on key-lay-over and key-phase-out if enabled.
83+
*/
84+
private[jwt] def fetchKeySetsFromCloud(secretsUtils: SecretUtils = AwsSecretsUtils): (KeyPair, Option[KeyPair]) = {
4685
try {
47-
val currentSecretsOption = AwsSecretsUtils.fetchSecret(
86+
val currentSecretsOption = secretsUtils.fetchSecret(
4887
secretName,
4988
region,
50-
Array(privateKeyFieldName, publicKeyFieldName)
89+
Array(privateKeyFieldName, publicKeyFieldName),
90+
None
5191
)
5292

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

60100
val previousSecretsOption =
61-
AwsSecretsUtils.fetchSecret(
62-
secretName,
63-
region,
64-
Array(privateKeyFieldName, publicKeyFieldName),
65-
Some("AWSPREVIOUS")
66-
)
101+
secretsUtils.fetchSecret(
102+
secretName,
103+
region,
104+
Array(privateKeyFieldName, publicKeyFieldName),
105+
Some("AWSPREVIOUS")
106+
)
67107

68108
val previousKeyPair = previousSecretsOption.flatMap { previousSecrets =>
69109
try {
70110
val keys = createKeyPair(previousSecrets.secretValue)
71111
logger.info("AWSPREVIOUS Key Data successfully retrieved and parsed from AWS Secrets Manager")
72-
val exp = keyPhaseOutTime.exists(isExpired(currentSecrets.createTime, _))
73-
if(exp) { None }
112+
val keyPhaseOutActive = keyPhaseOutTime.exists(kpot =>
113+
isExpired(currentSecrets.createTime, kpot + keyLayOverTime.getOrElse(Duration.Zero)))
114+
if(keyPhaseOutActive) { None }
74115
else { Some(keys) }
75116
} catch {
76117
case e: Throwable =>
@@ -79,41 +120,22 @@ case class AwsSecretsManagerKeyConfig(
79120
}
80121
}
81122

82-
(currentKeyPair, previousKeyPair)
123+
previousKeyPair.fold {(currentKeyPair, previousKeyPair)} { pk =>
124+
val keyLayOverActive = keyLayOverTime.exists(!isExpired(currentSecrets.createTime, _))
125+
if (!keyLayOverActive) {
126+
(currentKeyPair, previousKeyPair)
127+
}
128+
else {
129+
(pk, Some(currentKeyPair))
130+
}
131+
}
83132
} catch {
84133
case e: Throwable =>
85134
logger.error(s"Error occurred retrieving and decoding keys from AWS Secrets Manager", e)
86135
throw e
87136
}
88137
}
89138

90-
override def throwErrors(): Unit = this.validate().throwOnErrors()
91-
92-
override def validate(): ConfigValidationResult = {
93-
94-
val awsSecretsResults = Seq(
95-
Option(secretName)
96-
.map(_ => ConfigValidationSuccess)
97-
.getOrElse(ConfigValidationError(ConfigValidationException("secretName is empty"))),
98-
99-
Option(region)
100-
.map(_ => ConfigValidationSuccess)
101-
.getOrElse(ConfigValidationError(ConfigValidationException("region is empty"))),
102-
103-
Option(privateKeyFieldName)
104-
.map(_ => ConfigValidationSuccess)
105-
.getOrElse(ConfigValidationError(ConfigValidationException("privateKeyFieldName is empty"))),
106-
107-
Option(publicKeyFieldName)
108-
.map(_ => ConfigValidationSuccess)
109-
.getOrElse(ConfigValidationError(ConfigValidationException("publicKeyFieldName is empty"))),
110-
)
111-
112-
val awsSecretsResultsMerge = awsSecretsResults.foldLeft[ConfigValidationResult](ConfigValidationSuccess)(ConfigValidationResult.merge)
113-
114-
super.validate().merge(awsSecretsResultsMerge)
115-
}
116-
117139
private def createKeyPair(secretKeys: Map[String, String]): KeyPair = {
118140

119141
val publicKeySpec: X509EncodedKeySpec = new X509EncodedKeySpec(

api/src/main/scala/za/co/absa/loginsvc/rest/config/jwt/InMemoryKeyConfig.scala

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -23,13 +23,14 @@ import za.co.absa.loginsvc.rest.config.validation.{ConfigValidationException, Co
2323
import za.co.absa.loginsvc.rest.config.validation.ConfigValidationResult.{ConfigValidationError, ConfigValidationSuccess}
2424

2525
import java.security.KeyPair
26-
import scala.concurrent.duration.FiniteDuration
26+
import scala.concurrent.duration.{Duration, FiniteDuration}
2727

2828
case class InMemoryKeyConfig(
2929
algName: String,
3030
accessExpTime: FiniteDuration,
3131
refreshExpTime: FiniteDuration,
3232
keyRotationTime: Option[FiniteDuration],
33+
keyLayOverTime: Option[FiniteDuration],
3334
keyPhaseOutTime: Option[FiniteDuration]
3435
) extends KeyConfig {
3536

@@ -45,12 +46,13 @@ case class InMemoryKeyConfig(
4546
}
4647

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

53-
super.validate().merge(keyPhaseOutTimeResult)
55+
super.validate().merge(optionalKeyTimeResult)
5456
}
5557

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

api/src/main/scala/za/co/absa/loginsvc/rest/config/jwt/KeyConfig.scala

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ trait KeyConfig extends ConfigValidatable {
3030
def accessExpTime: FiniteDuration
3131
def refreshExpTime: FiniteDuration
3232
def keyRotationTime: Option[FiniteDuration]
33+
def keyLayOverTime: Option[FiniteDuration]
3334
def keyPhaseOutTime: Option[FiniteDuration]
3435
def keyPair(): (KeyPair, Option[KeyPair])
3536
def throwErrors(): Unit
@@ -79,6 +80,14 @@ trait KeyConfig extends ConfigValidatable {
7980
ConfigValidationError(ConfigValidationException(s"keyPhaseOutTime can only be enable if keyRotationTime is enable!"))
8081
} else ConfigValidationSuccess
8182

83+
val keyLayoverTimeResult = if (keyLayOverTime.nonEmpty && keyLayOverTime.get < KeyConfig.minKeyLayOverTime) {
84+
ConfigValidationError(ConfigValidationException(s"keyLayOverTime must be at least ${KeyConfig.minKeyLayOverTime}"))
85+
} else ConfigValidationSuccess
86+
87+
val keyLayOverWithRotationResult = if (keyLayOverTime.nonEmpty && keyRotationTime.isEmpty) {
88+
ConfigValidationError(ConfigValidationException(s"keyLayOverTime can only be enable if keyRotationTime is enable!"))
89+
} else ConfigValidationSuccess
90+
8291
if (keyRotationTime.isEmpty) {
8392
logger.warn("keyRotationTime is not set in config, key-pair will not be rotated!")
8493
}
@@ -93,6 +102,8 @@ trait KeyConfig extends ConfigValidatable {
93102
.merge(keyRotationTimeResult)
94103
.merge(keyPhaseOutTimeResult)
95104
.merge(keyPhaseOutWithRotationResult)
105+
.merge(keyLayoverTimeResult)
106+
.merge(keyLayOverWithRotationResult)
96107
}
97108
}
98109

@@ -101,4 +112,5 @@ object KeyConfig {
101112
val minRefreshExpTime: FiniteDuration = 10.milliseconds
102113
val minKeyRotationTime: FiniteDuration = 10.milliseconds
103114
val minKeyPhaseOutTime: FiniteDuration = 10.milliseconds
115+
val minKeyLayOverTime: FiniteDuration = 10.milliseconds
104116
}

0 commit comments

Comments
 (0)