Skip to content

Commit 7310765

Browse files
committed
feat: default refresh token rotation policy changed
The default `rotateRefreshToken` value puts forth a sensible refresh token rotation policy - only allows refresh tokens to be rotated (have their TTL prolonged by issuing a new one) for one year. - otherwise always rotate public client tokens - otherwise only rotate tokens if they're being used close to their expiration (>= 70% TTL passed) This remains to be just a default that you can modify or return to its original `true` value. BREAKING CHANGE: default `rotateRefreshToken` configuration value is now a function with a described policy that follows [OAuth 2.0 Security Best Current Practice](https://tools.ietf.org/html/draft-ietf-oauth-security-topics-12)
1 parent 663fadc commit 7310765

File tree

11 files changed

+108
-36
lines changed

11 files changed

+108
-36
lines changed

docs/README.md

Lines changed: 20 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -2324,7 +2324,7 @@ async issueRefreshToken(ctx, client, code) {
23242324
<br>
23252325

23262326

2327-
...if a client has the grant whitelisted and scope includes offline_access or the client is a public web client doing code flow. Configure `issueRefreshToken` like so
2327+
... If a client has the grant whitelisted and scope includes offline_access or the client is a public web client doing code flow. Configure `issueRefreshToken` like so
23282328

23292329

23302330
```js
@@ -2505,29 +2505,30 @@ _**default value**_:
25052505
Configures if and how the OP rotates refresh tokens after they are used. Supported values are
25062506
- `false` refresh tokens are not rotated and their initial expiration date is final
25072507
- `true` refresh tokens are rotated when used, current token is marked as consumed and new one is issued with new TTL, when a consumed refresh token is encountered an error is returned instead and the whole token chain (grant) is revoked
2508-
- function returning true/false, true when rotation should occur, false when it shouldn't
2508+
- `function` returning true/false, true when rotation should occur, false when it shouldn't
2509+
The default configuration value puts forth a sensible refresh token rotation policy
2510+
- only allows refresh tokens to be rotated (have their TTL prolonged by issuing a new one) for one year
2511+
- otherwise always rotate public client tokens
2512+
- otherwise only rotate tokens if they're being used close to their expiration (>= 70% TTL passed)
25092513

25102514

25112515
_**default value**_:
25122516
```js
2513-
true
2514-
```
2515-
<details>
2516-
<summary>(Click to expand) function use
2517-
</summary>
2518-
<br>
2519-
2520-
```js
2521-
async function rotateRefreshToken(ctx) {
2522-
// e.g.
2523-
// return refreshTokenCloseToExpiration(ctx.oidc.entities.RefreshToken);
2524-
// or
2525-
// return refreshTokenRecentlyRotated(ctx.oidc.entities.RefreshToken);
2526-
// or
2527-
// return customClientBasedPolicy(ctx.oidc.entities.Client);
2517+
rotateRefreshToken(ctx) {
2518+
const { RefreshToken: refreshToken, Client: client } = ctx.oidc.entities;
2519+
// cap the maximum amount of time a refresh token can be
2520+
// rotated for up to 1 year, afterwards its TTL is final
2521+
if (refreshToken.totalLifetime() >= 365.25 * 24 * 60 * 60) {
2522+
return false;
2523+
}
2524+
// rotate public client refresh tokens
2525+
if (client.tokenEndpointAuthMethod === 'none') {
2526+
return true;
2527+
}
2528+
// rotate if the token is nearing expiration (it's beyond 70% of its lifetime)
2529+
return refreshToken.ttlPercentagePassed() >= 70;
25282530
}
25292531
```
2530-
</details>
25312532

25322533
### routes
25332534

@@ -2677,6 +2678,7 @@ When doing that be sure to remove the client provided headers of the same name o
26772678
Expirations (in seconds, or dynamically returned value) for all token types
26782679

26792680

2681+
_**recommendation**_: Do not set token TTLs longer then they absolutely have to be, the shorter the TTL, the better. Rather than setting crazy high Refresh Token TTL look into `rotateRefreshToken` configuration option which is set up in way that when refresh tokens are regularly used they will have their TTL refreshed (via rotation). This is inline with the [OAuth 2.0 Security Best Current Practice](https://tools.ietf.org/html/draft-ietf-oauth-security-topics-12)
26802682

26812683
_**default value**_:
26822684
```js

example/my_adapter.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,8 @@ class MyAdapter {
6060
* - nonce {string} - random nonce from an authorization request
6161
* - redirectUri {string} - redirect_uri value from an authorization request
6262
* - resource {string} - granted or requested resource indicator value (auth code, device code, refresh token)
63+
* - rotations {number} - [RefreshToken only] - number of times the refresh token was rotated
64+
* - iiat {number} - [RefreshToken only] - the very first (initial) issued at before rotations
6365
* - acr {string} - authentication context class reference value
6466
* - amr {string[]} - Authentication methods references
6567
* - scope {string} - scope value from an authorization request, rejected scopes are removed

lib/actions/grants/authorization_code.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,7 @@ module.exports.handler = async function authorizationCodeHandler(ctx, next) {
112112
gty,
113113
nonce: code.nonce,
114114
resource: code.resource,
115+
rotations: 0,
115116
scope: code.scope,
116117
sessionUid: code.sessionUid,
117118
sid: code.sid,

lib/actions/grants/device_code.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,7 @@ module.exports.handler = async function deviceCodeHandler(ctx, next) {
115115
grantId: code.grantId,
116116
gty,
117117
nonce: code.nonce,
118+
rotations: 0,
118119
scope: code.scope,
119120
sessionUid: code.sessionUid,
120121
sid: code.sid,

lib/actions/grants/refresh_token.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,10 +97,12 @@ module.exports.handler = async function refreshTokenHandler(ctx, next) {
9797
claims: refreshToken.claims,
9898
client: ctx.oidc.client,
9999
expiresWithSession: refreshToken.expiresWithSession,
100+
iiat: refreshToken.iiat,
100101
grantId: refreshToken.grantId,
101102
gty: refreshToken.gty,
102103
nonce: refreshToken.nonce,
103104
resource: refreshToken.resource,
105+
rotations: typeof refreshToken.rotations === 'number' ? refreshToken.rotations + 1 : 1,
104106
scope: refreshToken.scope,
105107
sessionUid: refreshToken.sessionUid,
106108
sid: refreshToken.sid,

lib/helpers/defaults.js

Lines changed: 32 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1184,7 +1184,7 @@ const DEFAULTS = {
11841184
* description: Helper used by the OP to decide whether a refresh token will be issued or not
11851185
*
11861186
* example: To always issue a refresh tokens ...
1187-
* ...if a client has the grant whitelisted and scope includes offline_access or the client is a
1187+
* ... if a client has the grant whitelisted and scope includes offline_access or the client is a
11881188
* public web client doing code flow. Configure `issueRefreshToken` like so
11891189
*
11901190
* ```js
@@ -1505,6 +1505,14 @@ const DEFAULTS = {
15051505
*
15061506
* description: Expirations (in seconds, or dynamically returned value) for all token types
15071507
*
1508+
* recommendation: Do not set token TTLs longer then they absolutely have to be, the shorter
1509+
* the TTL, the better.
1510+
*
1511+
* recommendation: Rather than setting crazy high Refresh Token TTL look into `rotateRefreshToken`
1512+
* configuration option which is set up in way that when refresh tokens are regularly used they
1513+
* will have their TTL refreshed (via rotation). This is inline with the
1514+
* [OAuth 2.0 Security Best Current Practice](https://tools.ietf.org/html/draft-ietf-oauth-security-topics-12)
1515+
*
15081516
* example: To resolve a ttl on runtime for each new token
15091517
* Configure `ttl` for a given token type with a function like so, this must return a value, not a
15101518
* Promise.
@@ -1797,20 +1805,30 @@ const DEFAULTS = {
17971805
* - `true` refresh tokens are rotated when used, current token is marked as
17981806
* consumed and new one is issued with new TTL, when a consumed refresh token is
17991807
* encountered an error is returned instead and the whole token chain (grant) is revoked
1800-
* - function returning true/false, true when rotation should occur, false when it shouldn't
1801-
* example: function use
1802-
* ```js
1803-
* async function rotateRefreshToken(ctx) {
1804-
* // e.g.
1805-
* // return refreshTokenCloseToExpiration(ctx.oidc.entities.RefreshToken);
1806-
* // or
1807-
* // return refreshTokenRecentlyRotated(ctx.oidc.entities.RefreshToken);
1808-
* // or
1809-
* // return customClientBasedPolicy(ctx.oidc.entities.Client);
1810-
* }
1811-
* ```
1808+
* - `function` returning true/false, true when rotation should occur, false when it shouldn't
1809+
*
1810+
* The default configuration value puts forth a sensible refresh token rotation policy
1811+
* - only allows refresh tokens to be rotated (have their TTL prolonged by issuing a new one) for one year
1812+
* - otherwise always rotate public client tokens
1813+
* - otherwise only rotate tokens if they're being used close to their expiration (>= 70% TTL passed)
18121814
*/
1813-
rotateRefreshToken: true,
1815+
rotateRefreshToken(ctx) {
1816+
const { RefreshToken: refreshToken, Client: client } = ctx.oidc.entities;
1817+
1818+
// cap the maximum amount of time a refresh token can be
1819+
// rotated for up to 1 year, afterwards its TTL is final
1820+
if (refreshToken.totalLifetime() >= 365.25 * 24 * 60 * 60) {
1821+
return false;
1822+
}
1823+
1824+
// rotate public client refresh tokens
1825+
if (client.tokenEndpointAuthMethod === 'none') {
1826+
return true;
1827+
}
1828+
1829+
// rotate if the token is nearing expiration (it's beyond 70% of its lifetime)
1830+
return refreshToken.ttlPercentagePassed() >= 70;
1831+
},
18141832

18151833

18161834
/*

lib/models/base_token.js

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,18 @@ module.exports = function getBaseToken(provider) {
3434
return undefined;
3535
}
3636

37+
/*
38+
* ttlPercentagePassed
39+
* returns a Number (0 to 100) with the value being percentage of the token's ttl already
40+
* passed. The higher the percentage the older the token is. At 0 the token is fresh, at a 100
41+
* it is expired.
42+
*/
43+
ttlPercentagePassed() {
44+
const now = epochTime();
45+
const percentage = Math.floor(100 * ((now - this.iat) / (this.exp - this.iat)));
46+
return Math.max(Math.min(100, percentage), 0);
47+
}
48+
3749
get isValid() { return !this.isExpired; }
3850

3951
get isExpired() { return this.exp <= epochTime(); }

lib/models/refresh_token.js

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
const epochTime = require('../helpers/epoch_time');
2+
13
const apply = require('./mixins/apply');
24
const consumable = require('./mixins/consumable');
35
const hasFormat = require('./mixins/has_format');
@@ -15,4 +17,28 @@ module.exports = provider => class RefreshToken extends apply([
1517
isSessionBound(provider),
1618
storesAuth,
1719
hasFormat(provider, 'RefreshToken', provider.BaseToken),
18-
]) {};
20+
]) {
21+
constructor(...args) {
22+
super(...args);
23+
if (!this.iiat) {
24+
this.iiat = this.iat || epochTime();
25+
}
26+
}
27+
28+
static get IN_PAYLOAD() {
29+
return [
30+
...super.IN_PAYLOAD,
31+
32+
'rotations',
33+
'iiat',
34+
];
35+
}
36+
37+
/*
38+
* totalLifetime()
39+
* number of seconds since the very first refresh token chain iat
40+
*/
41+
totalLifetime() {
42+
return epochTime() - this.iiat;
43+
}
44+
};

test/certificate_bound_access_tokens/certificate_bound_access_tokens.test.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -222,7 +222,7 @@ describe('features.certificateBoundAccessTokens', () => {
222222
expect(spy).to.have.property('calledOnce', true);
223223
const { oidc: { entities: { AccessToken, RefreshToken } } } = spy.args[0][0];
224224
expect(AccessToken).to.have.property('x5t#S256', expectedS256);
225-
expect(RefreshToken).to.have.property('x5t#S256', undefined);
225+
expect(RefreshToken['x5t#S256']).to.be.undefined;
226226
});
227227

228228
it('verifies the request made with mutual-TLS', async function () {

test/storage/jwt.test.js

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,8 @@ if (FORMAT === 'jwt') {
4040
const policies = ['foo'];
4141
const sessionUid = 'foo';
4242
const expiresWithSession = false;
43+
const iiat = epochTime();
44+
const rotations = 1;
4345

4446
// TODO: add Session and Interaction
4547

@@ -48,7 +50,7 @@ if (FORMAT === 'jwt') {
4850
accountId, claims, clientId, grantId, scope, sid, consumed, acr, amr, authTime, nonce,
4951
redirectUri, codeChallenge, codeChallengeMethod, aud, error, errorDescription, params,
5052
userCode, deviceInfo, gty, resource, policies, sessionUid, expiresWithSession,
51-
'x5t#S256': s256, inFlight,
53+
'x5t#S256': s256, inFlight, iiat, rotations,
5254
};
5355
/* eslint-enable object-property-newline */
5456

@@ -157,6 +159,8 @@ if (FORMAT === 'jwt') {
157159
assert.calledWith(upsert, string, {
158160
accountId,
159161
acr,
162+
iiat,
163+
rotations,
160164
amr,
161165
authTime,
162166
claims,

test/storage/opaque.test.js

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,8 @@ if (FORMAT === 'opaque') {
3535
const policies = ['foo'];
3636
const sessionUid = 'foo';
3737
const expiresWithSession = false;
38+
const iiat = epochTime();
39+
const rotations = 1;
3840

3941
// TODO: add Session and Interaction
4042

@@ -43,7 +45,7 @@ if (FORMAT === 'opaque') {
4345
accountId, claims, clientId, grantId, scope, sid, consumed, acr, amr, authTime, nonce,
4446
redirectUri, codeChallenge, codeChallengeMethod, aud, error, errorDescription, params,
4547
userCode, deviceInfo, gty, resource, policies, sessionUid, expiresWithSession,
46-
'x5t#S256': s256, inFlight,
48+
'x5t#S256': s256, inFlight, iiat, rotations,
4749
};
4850
/* eslint-enable object-property-newline */
4951

@@ -166,6 +168,8 @@ if (FORMAT === 'opaque') {
166168
amr,
167169
authTime,
168170
claims,
171+
iiat,
172+
rotations,
169173
clientId,
170174
consumed,
171175
exp: number,

0 commit comments

Comments
 (0)