Skip to content

Commit

Permalink
feat: replay prevention for client assertions is now built in
Browse files Browse the repository at this point in the history
This removes the need for a custom built `uniqueness` helper, instead
it'll use the storage adapter to store values. This mechanism will be
utilized by future DPoP feature.
  • Loading branch information
panva committed Apr 7, 2019
1 parent 74bfe9b commit a22d6ce
Show file tree
Hide file tree
Showing 11 changed files with 72 additions and 45 deletions.
16 changes: 0 additions & 16 deletions docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,6 @@ If you or your business use oidc-provider, please consider becoming a [Patron][s
- [subjectTypes](#subjecttypes)
- [tokenEndpointAuthMethods](#tokenendpointauthmethods)
- [ttl](#ttl)
- [uniqueness](#uniqueness)
- [whitelistedJWA](#whitelistedjwa)


Expand Down Expand Up @@ -2692,21 +2691,6 @@ Configure `ttl` for a given token type with a function like so, this must return
```
</details>
### uniqueness
Function resolving whether a given value with expiration is presented first time
_**recommendation**_: configure this option to use a shared store if client_secret_jwt and private_key_jwt are used
_**default value**_:
```js
async uniqueness(ctx, jti, expiresAt) {
if (cache.get(jti)) return false;
cache.set(jti, true, (expiresAt - epochTime()) * 1000);
return true;
}
```
### whitelistedJWA
Fine-tune the algorithms your provider will support by declaring algorithm values for each respective JWA use
Expand Down
10 changes: 8 additions & 2 deletions example/my_adapter.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ class MyAdapter {
* @constructor
* @param {string} name Name of the oidc-provider model. One of "Session", "AccessToken",
* "AuthorizationCode", "RefreshToken", "ClientCredentials", "Client", "InitialAccessToken",
* "RegistrationAccessToken", "DeviceCode" or "Interaction"
* "RegistrationAccessToken", "DeviceCode", "Interaction" or "ReplayDetection"
*
*/
constructor(name) {
Expand Down Expand Up @@ -106,7 +106,7 @@ class MyAdapter {
*
* Short-lived Interaction model payload contains the following properties:
* - jti {string} - unique identifier of the interaction session
* - kind {string} "Interaction" fixed string value
* - kind {string} - "Interaction" fixed string value
* - exp {number} - timestamp of the interaction's expiration
* - iat {number} - timestamp of the interaction's creation
* - uid {number} - the uid of the authorizing client's established session
Expand All @@ -122,6 +122,12 @@ class MyAdapter {
* - session.amr {string[]} - existing amr of the session Interaction belongs to
* - session.accountId {string} - existing account id from the seession Interaction belongs to
*
* Replay prevention ReplayDetection model contains the following properties:
* - jti {string} - unique identifier of the replay object
* - kind {string} - "ReplayDetection" fixed string value
* - exp {number} - timestamp of the replay object cache expiration
* - iat {number} - timestamp of the replay object cache's creation
* - replay {object} - the object replay prevention is calculated from
*/
}

Expand Down
1 change: 0 additions & 1 deletion lib/helpers/configuration.js
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,6 @@ module.exports = class Configuration {
// // this.issueRefreshToken
// // this.postLogoutRedirectUri
// // this.logoutSource
// // this.uniqueness
// // this.renderError
// // this.interactionUrl
// // this.audiences
Expand Down
17 changes: 0 additions & 17 deletions lib/helpers/defaults.js
Original file line number Diff line number Diff line change
Expand Up @@ -1409,23 +1409,6 @@ const DEFAULTS = {
},


/*
* uniqueness
*
* description: Function resolving whether a given value with expiration is presented first time
* recommendation: configure this option to use a shared store if client_secret_jwt and
* private_key_jwt are used
*/
async uniqueness(ctx, jti, expiresAt) {
mustChange('uniqueness', 'have the values unique-checked across processes');
if (cache.get(jti)) return false;

cache.set(jti, true, (expiresAt - epochTime()) * 1000);

return true;
},


/*
* renderError
*
Expand Down
8 changes: 8 additions & 0 deletions lib/helpers/jwt.js
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,14 @@ class JWT {
assert(timestamp - clockTolerance < payload.exp, 'jwt expired');
}

if (typeof payload.jti !== 'undefined') {
assert.deepEqual(typeof payload.jti, 'string', 'invalid jti value');
}

if (typeof payload.iss !== 'undefined') {
assert.deepEqual(typeof payload.iss, 'string', 'invalid iss value');
}

if (jti) {
assert.deepEqual(payload.jti, jti, 'jwt jti invalid');
}
Expand Down
2 changes: 2 additions & 0 deletions lib/models/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ const getInitialAccessToken = require('./initial_access_token');
const getInteraction = require('./interaction');
const getRefreshToken = require('./refresh_token');
const getRegistrationAccessToken = require('./registration_access_token');
const getReplayDetection = require('./replay_detection');
const getSession = require('./session');

module.exports = {
Expand All @@ -25,5 +26,6 @@ module.exports = {
getInteraction,
getRefreshToken,
getRegistrationAccessToken,
getReplayDetection,
getSession,
};
2 changes: 1 addition & 1 deletion lib/models/mixins/has_format.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ module.exports = (provider, type, superclass) => {
let { [type]: FORMAT } = config;
let { default: DEFAULT = 'opaque' } = config;

if (type === 'Session' || type === 'Interaction') {
if (type === 'Session' || type === 'Interaction' || type === 'ReplayDetection') {
FORMAT = 'opaque';
}

Expand Down
43 changes: 43 additions & 0 deletions lib/models/replay_detection.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
const hash = require('object-hash');
const base64url = require('base64url');

const instance = require('../helpers/weak_cache');
const epochTime = require('../helpers/epoch_time');

const hasFormat = require('./mixins/has_format');

const fingerprint = properties => base64url(hash(properties, {
ignoreUnknown: true,
unorderedArrays: true,
encoding: 'buffer',
algorithm: 'sha256',
}));

module.exports = provider => class ReplayDetection extends hasFormat(provider, 'ReplayDetection', instance(provider).BaseModel) {
static get IN_PAYLOAD() {
return [
...super.IN_PAYLOAD,
'iss',
];
}

static async unique(iss, jti, exp) {
const id = fingerprint({ iss, jti });

const found = await this.find(id);

if (found) {
return false;
}

const inst = new this({
jti: id,
exp,
iss,
});

await inst.save(exp - epochTime());

return true;
}
};
3 changes: 3 additions & 0 deletions lib/provider.js
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,7 @@ class Provider extends events.EventEmitter {
instance(this).ClientCredentials = models.getClientCredentials(this);
instance(this).InitialAccessToken = models.getInitialAccessToken(this);
instance(this).RegistrationAccessToken = models.getRegistrationAccessToken(this);
instance(this).ReplayDetection = models.getReplayDetection(this);
instance(this).DeviceCode = models.getDeviceCode(this);
instance(this).OIDCContext = getContext(this);
const { pathname } = url.parse(this.issuer);
Expand Down Expand Up @@ -352,6 +353,8 @@ class Provider extends events.EventEmitter {

get DeviceCode() { return instance(this).DeviceCode; }

get ReplayDetection() { return instance(this).ReplayDetection; }

get requestUriCache() { return instance(this).requestUriCache; }
}

Expand Down
13 changes: 6 additions & 7 deletions lib/shared/token_jwt_auth.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ const JWT = require('../helpers/jwt');

module.exports = function getTokenJwtAuth(provider, endpoint) {
return async function tokenJwtAuth(ctx, keystore, algorithms) {
const uniqueCheck = instance(provider).configuration('uniqueness');
const endpointUri = ctx.oidc.urlFor(endpoint);

const { header, payload } = JWT.decode(ctx.oidc.params.client_assertion);
Expand Down Expand Up @@ -41,12 +40,6 @@ module.exports = function getTokenJwtAuth(provider, endpoint) {
throw new InvalidClientAuth('audience (aud) must equal the endpoint url');
}

const unique = await uniqueCheck(ctx, payload.jti, payload.exp);

if (!unique) {
throw new InvalidClientAuth('jwt-bearer tokens must only be used once');
}

try {
await JWT.verify(ctx.oidc.params.client_assertion, keystore, {
audience: endpointUri,
Expand All @@ -57,5 +50,11 @@ module.exports = function getTokenJwtAuth(provider, endpoint) {
} catch (err) {
throw new InvalidClientAuth(err.message);
}

const unique = await provider.ReplayDetection.unique(payload.iss, payload.jti, payload.exp);

if (!unique) {
throw new InvalidClientAuth('client assertion tokens must only be used once');
}
};
};
2 changes: 1 addition & 1 deletion test/client_auth/client_auth.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -851,7 +851,7 @@ describe('client authentication options', () => {
.expect(tokenAuthRejected)
.expect(() => {
expect(spy.calledOnce).to.be.true;
expect(errorDetail(spy)).to.equal('jwt-bearer tokens must only be used once');
expect(errorDetail(spy)).to.equal('client assertion tokens must only be used once');
})));
});
});
Expand Down

0 comments on commit a22d6ce

Please sign in to comment.