Skip to content

Commit

Permalink
feat: default refresh token rotation policy changed
Browse files Browse the repository at this point in the history
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)
  • Loading branch information
panva committed Jun 6, 2019
1 parent 663fadc commit 7310765
Show file tree
Hide file tree
Showing 11 changed files with 108 additions and 36 deletions.
38 changes: 20 additions & 18 deletions docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2324,7 +2324,7 @@ async issueRefreshToken(ctx, client, code) {
<br>


...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
... 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


```js
Expand Down Expand Up @@ -2505,29 +2505,30 @@ _**default value**_:
Configures if and how the OP rotates refresh tokens after they are used. Supported values are
- `false` refresh tokens are not rotated and their initial expiration date is final
- `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
- function returning true/false, true when rotation should occur, false when it shouldn't
- `function` returning true/false, true when rotation should occur, false when it shouldn't
The default configuration 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)


_**default value**_:
```js
true
```
<details>
<summary>(Click to expand) function use
</summary>
<br>

```js
async function rotateRefreshToken(ctx) {
// e.g.
// return refreshTokenCloseToExpiration(ctx.oidc.entities.RefreshToken);
// or
// return refreshTokenRecentlyRotated(ctx.oidc.entities.RefreshToken);
// or
// return customClientBasedPolicy(ctx.oidc.entities.Client);
rotateRefreshToken(ctx) {
const { RefreshToken: refreshToken, Client: client } = ctx.oidc.entities;
// cap the maximum amount of time a refresh token can be
// rotated for up to 1 year, afterwards its TTL is final
if (refreshToken.totalLifetime() >= 365.25 * 24 * 60 * 60) {
return false;
}
// rotate public client refresh tokens
if (client.tokenEndpointAuthMethod === 'none') {
return true;
}
// rotate if the token is nearing expiration (it's beyond 70% of its lifetime)
return refreshToken.ttlPercentagePassed() >= 70;
}
```
</details>

### routes

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


_**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)

_**default value**_:
```js
Expand Down
2 changes: 2 additions & 0 deletions example/my_adapter.js
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,8 @@ class MyAdapter {
* - nonce {string} - random nonce from an authorization request
* - redirectUri {string} - redirect_uri value from an authorization request
* - resource {string} - granted or requested resource indicator value (auth code, device code, refresh token)
* - rotations {number} - [RefreshToken only] - number of times the refresh token was rotated
* - iiat {number} - [RefreshToken only] - the very first (initial) issued at before rotations
* - acr {string} - authentication context class reference value
* - amr {string[]} - Authentication methods references
* - scope {string} - scope value from an authorization request, rejected scopes are removed
Expand Down
1 change: 1 addition & 0 deletions lib/actions/grants/authorization_code.js
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,7 @@ module.exports.handler = async function authorizationCodeHandler(ctx, next) {
gty,
nonce: code.nonce,
resource: code.resource,
rotations: 0,
scope: code.scope,
sessionUid: code.sessionUid,
sid: code.sid,
Expand Down
1 change: 1 addition & 0 deletions lib/actions/grants/device_code.js
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,7 @@ module.exports.handler = async function deviceCodeHandler(ctx, next) {
grantId: code.grantId,
gty,
nonce: code.nonce,
rotations: 0,
scope: code.scope,
sessionUid: code.sessionUid,
sid: code.sid,
Expand Down
2 changes: 2 additions & 0 deletions lib/actions/grants/refresh_token.js
Original file line number Diff line number Diff line change
Expand Up @@ -97,10 +97,12 @@ module.exports.handler = async function refreshTokenHandler(ctx, next) {
claims: refreshToken.claims,
client: ctx.oidc.client,
expiresWithSession: refreshToken.expiresWithSession,
iiat: refreshToken.iiat,
grantId: refreshToken.grantId,
gty: refreshToken.gty,
nonce: refreshToken.nonce,
resource: refreshToken.resource,
rotations: typeof refreshToken.rotations === 'number' ? refreshToken.rotations + 1 : 1,
scope: refreshToken.scope,
sessionUid: refreshToken.sessionUid,
sid: refreshToken.sid,
Expand Down
46 changes: 32 additions & 14 deletions lib/helpers/defaults.js
Original file line number Diff line number Diff line change
Expand Up @@ -1184,7 +1184,7 @@ const DEFAULTS = {
* description: Helper used by the OP to decide whether a refresh token will be issued or not
*
* example: To always issue a refresh tokens ...
* ...if a client has the grant whitelisted and scope includes offline_access or the client is a
* ... 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
*
* ```js
Expand Down Expand Up @@ -1505,6 +1505,14 @@ const DEFAULTS = {
*
* description: Expirations (in seconds, or dynamically returned value) for all token types
*
* recommendation: Do not set token TTLs longer then they absolutely have to be, the shorter
* the TTL, the better.
*
* recommendation: 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)
*
* example: To resolve a ttl on runtime for each new token
* Configure `ttl` for a given token type with a function like so, this must return a value, not a
* Promise.
Expand Down Expand Up @@ -1797,20 +1805,30 @@ const DEFAULTS = {
* - `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
* - function returning true/false, true when rotation should occur, false when it shouldn't
* example: function use
* ```js
* async function rotateRefreshToken(ctx) {
* // e.g.
* // return refreshTokenCloseToExpiration(ctx.oidc.entities.RefreshToken);
* // or
* // return refreshTokenRecentlyRotated(ctx.oidc.entities.RefreshToken);
* // or
* // return customClientBasedPolicy(ctx.oidc.entities.Client);
* }
* ```
* - `function` returning true/false, true when rotation should occur, false when it shouldn't
*
* The default configuration 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)
*/
rotateRefreshToken: true,
rotateRefreshToken(ctx) {
const { RefreshToken: refreshToken, Client: client } = ctx.oidc.entities;

// cap the maximum amount of time a refresh token can be
// rotated for up to 1 year, afterwards its TTL is final
if (refreshToken.totalLifetime() >= 365.25 * 24 * 60 * 60) {
return false;
}

// rotate public client refresh tokens
if (client.tokenEndpointAuthMethod === 'none') {
return true;
}

// rotate if the token is nearing expiration (it's beyond 70% of its lifetime)
return refreshToken.ttlPercentagePassed() >= 70;
},


/*
Expand Down
12 changes: 12 additions & 0 deletions lib/models/base_token.js
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,18 @@ module.exports = function getBaseToken(provider) {
return undefined;
}

/*
* ttlPercentagePassed
* returns a Number (0 to 100) with the value being percentage of the token's ttl already
* passed. The higher the percentage the older the token is. At 0 the token is fresh, at a 100
* it is expired.
*/
ttlPercentagePassed() {
const now = epochTime();
const percentage = Math.floor(100 * ((now - this.iat) / (this.exp - this.iat)));
return Math.max(Math.min(100, percentage), 0);
}

get isValid() { return !this.isExpired; }

get isExpired() { return this.exp <= epochTime(); }
Expand Down
28 changes: 27 additions & 1 deletion lib/models/refresh_token.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
const epochTime = require('../helpers/epoch_time');

const apply = require('./mixins/apply');
const consumable = require('./mixins/consumable');
const hasFormat = require('./mixins/has_format');
Expand All @@ -15,4 +17,28 @@ module.exports = provider => class RefreshToken extends apply([
isSessionBound(provider),
storesAuth,
hasFormat(provider, 'RefreshToken', provider.BaseToken),
]) {};
]) {
constructor(...args) {
super(...args);
if (!this.iiat) {
this.iiat = this.iat || epochTime();
}
}

static get IN_PAYLOAD() {
return [
...super.IN_PAYLOAD,

'rotations',
'iiat',
];
}

/*
* totalLifetime()
* number of seconds since the very first refresh token chain iat
*/
totalLifetime() {
return epochTime() - this.iiat;
}
};
Original file line number Diff line number Diff line change
Expand Up @@ -222,7 +222,7 @@ describe('features.certificateBoundAccessTokens', () => {
expect(spy).to.have.property('calledOnce', true);
const { oidc: { entities: { AccessToken, RefreshToken } } } = spy.args[0][0];
expect(AccessToken).to.have.property('x5t#S256', expectedS256);
expect(RefreshToken).to.have.property('x5t#S256', undefined);
expect(RefreshToken['x5t#S256']).to.be.undefined;
});

it('verifies the request made with mutual-TLS', async function () {
Expand Down
6 changes: 5 additions & 1 deletion test/storage/jwt.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,8 @@ if (FORMAT === 'jwt') {
const policies = ['foo'];
const sessionUid = 'foo';
const expiresWithSession = false;
const iiat = epochTime();
const rotations = 1;

// TODO: add Session and Interaction

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

Expand Down Expand Up @@ -157,6 +159,8 @@ if (FORMAT === 'jwt') {
assert.calledWith(upsert, string, {
accountId,
acr,
iiat,
rotations,
amr,
authTime,
claims,
Expand Down
6 changes: 5 additions & 1 deletion test/storage/opaque.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@ if (FORMAT === 'opaque') {
const policies = ['foo'];
const sessionUid = 'foo';
const expiresWithSession = false;
const iiat = epochTime();
const rotations = 1;

// TODO: add Session and Interaction

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

Expand Down Expand Up @@ -166,6 +168,8 @@ if (FORMAT === 'opaque') {
amr,
authTime,
claims,
iiat,
rotations,
clientId,
consumed,
exp: number,
Expand Down

0 comments on commit 7310765

Please sign in to comment.