Skip to content

Commit

Permalink
feat: support DPoP nonces
Browse files Browse the repository at this point in the history
  • Loading branch information
panva committed Dec 1, 2022
1 parent 917507f commit 8d82988
Show file tree
Hide file tree
Showing 15 changed files with 331 additions and 27 deletions.
31 changes: 30 additions & 1 deletion docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -860,10 +860,39 @@ _**default value**_:
```js
{
ack: undefined,
enabled: false
enabled: false,
nonceSecret: undefined,
requireNonce: [Function: requireNonce] // see expanded details below
}
```

<details><summary>(Click to expand) features.dPoP options details</summary><br>


#### nonceSecret

A secret value used for generating server-provided DPoP nonces. Must be a 32-byte length Buffer instance when provided.


_**default value**_:
```js
undefined
```

#### requireNonce

Function used to determine whether a DPoP nonce is required or not.


_**default value**_:
```js
function requireNonce(ctx) {
return false;
}
```

</details>

### features.devInteractions

Development-ONLY out of the box interaction views bundled with the library allow you to skip the boring frontend part while experimenting with oidc-provider. Enter any username (will be used as sub claim value) and any password to proceed.
Expand Down
2 changes: 1 addition & 1 deletion lib/actions/authorization/check_dpop_jkt.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ module.exports = async function checkDpopJkt(ctx, next) {
const unique = await ReplayDetection.unique(
ctx.oidc.client.clientId,
dPoP.jti,
epochTime() + 60,
epochTime() + 300,
);

ctx.assert(unique, new InvalidRequest('DPoP proof JWT Replay detected'));
Expand Down
2 changes: 1 addition & 1 deletion lib/actions/grants/authorization_code.js
Original file line number Diff line number Diff line change
Expand Up @@ -132,7 +132,7 @@ module.exports.handler = async function authorizationCodeHandler(ctx, next) {
const unique = await ReplayDetection.unique(
ctx.oidc.client.clientId,
dPoP.jti,
epochTime() + 60,
epochTime() + 300,
);

ctx.assert(unique, new InvalidGrant('DPoP proof JWT Replay detected'));
Expand Down
2 changes: 1 addition & 1 deletion lib/actions/grants/ciba.js
Original file line number Diff line number Diff line change
Expand Up @@ -133,7 +133,7 @@ module.exports.handler = async function cibaHandler(ctx, next) {
const unique = await ReplayDetection.unique(
ctx.oidc.client.clientId,
dPoP.jti,
epochTime() + 60,
epochTime() + 300,
);

ctx.assert(unique, new InvalidGrant('DPoP proof JWT Replay detected'));
Expand Down
2 changes: 1 addition & 1 deletion lib/actions/grants/client_credentials.js
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ module.exports.handler = async function clientCredentialsHandler(ctx, next) {
}

if (dPoP) {
const unique = await ReplayDetection.unique(client.clientId, dPoP.jti, epochTime() + 60);
const unique = await ReplayDetection.unique(client.clientId, dPoP.jti, epochTime() + 300);

ctx.assert(unique, new InvalidGrant('DPoP proof JWT Replay detected'));

Expand Down
2 changes: 1 addition & 1 deletion lib/actions/grants/device_code.js
Original file line number Diff line number Diff line change
Expand Up @@ -132,7 +132,7 @@ module.exports.handler = async function deviceCodeHandler(ctx, next) {
const unique = await ReplayDetection.unique(
ctx.oidc.client.clientId,
dPoP.jti,
epochTime() + 60,
epochTime() + 300,
);

ctx.assert(unique, new InvalidGrant('DPoP proof JWT Replay detected'));
Expand Down
2 changes: 1 addition & 1 deletion lib/actions/grants/refresh_token.js
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,7 @@ module.exports.handler = async function refreshTokenHandler(ctx, next) {
}

if (dPoP) {
const unique = await ReplayDetection.unique(client.clientId, dPoP.jti, epochTime() + 60);
const unique = await ReplayDetection.unique(client.clientId, dPoP.jti, epochTime() + 300);

ctx.assert(unique, new InvalidGrant('DPoP proof JWT Replay detected'));
}
Expand Down
6 changes: 3 additions & 3 deletions lib/actions/userinfo.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
const {
InvalidToken, InsufficientScope, InvalidDpopProof,
InvalidToken, InsufficientScope, InvalidDpopProof, UseDpopNonce,
} = require('../helpers/errors');
const difference = require('../helpers/_/difference');
const setWWWAuthenticate = require('../helpers/set_www_authenticate');
Expand Down Expand Up @@ -45,7 +45,7 @@ module.exports = [
scheme = 'Bearer';
}

if (err instanceof InvalidDpopProof) {
if (err instanceof InvalidDpopProof || err instanceof UseDpopNonce) {
// eslint-disable-next-line no-multi-assign
err.status = err.statusCode = 401;
}
Expand Down Expand Up @@ -98,7 +98,7 @@ module.exports = [
const unique = await ctx.oidc.provider.ReplayDetection.unique(
accessToken.clientId,
dPoP.jti,
epochTime() + 60,
epochTime() + 300,
);

ctx.assert(unique, new InvalidToken('DPoP proof JWT Replay detected'));
Expand Down
17 changes: 17 additions & 0 deletions lib/helpers/defaults.js
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,10 @@ async function userCodeInputSource(ctx, form, out, err) {
</html>`;
}

function requireNonce(ctx) {
return false;
}

async function userCodeConfirmSource(ctx, form, client, deviceInfo, userCode) {
// @param ctx - koa request context
// @param form - form source (id="op.deviceConfirmForm") to be embedded in the page and
Expand Down Expand Up @@ -904,6 +908,19 @@ function getDefaults() {
dPoP: {
enabled: false,
ack: undefined,
/**
* features.dPoP.nonceSecret
*
* description: A secret value used for generating server-provided DPoP nonces.
* Must be a 32-byte length Buffer instance when provided.
*/
nonceSecret: undefined,
/**
* features.dPoP.requireNonce
*
* description: Function used to determine whether a DPoP nonce is required or not.
*/
requireNonce,
},

/*
Expand Down
99 changes: 99 additions & 0 deletions lib/helpers/dpop_nonces.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
/* eslint-disable no-plusplus, no-bitwise, no-param-reassign, no-restricted-syntax */

const { createHmac } = require('crypto');

const base64url = require('./base64url');

function sixfourbeify(value) {
const buf = Buffer.alloc(8);
for (let i = buf.length - 1; i >= 0; i--) {
buf[i] = value & 0xff;
value >>= 8;
}

return buf;
}

function compute(secret, step) {
return base64url.encodeBuffer(createHmac('sha256', secret).update(sixfourbeify(step)).digest());
}

function compare(server, client) {
let result = 0;

if (server.length !== client.length) {
result = 1;
client = server;
}

for (let i = 0; i < server.length; i++) {
result |= server.charCodeAt(i) ^ client.charCodeAt(i);
}

return result;
}

const STEP = 60;

module.exports = class DPoPNonces {
#counter;

#secret;

#prevprev;

#prev;

#now;

#next;

#nextnext;

constructor(secret) {
if (!Buffer.isBuffer(secret) || secret.byteLength !== 32) {
throw new TypeError('features.dPoP.nonceSecret must be a 32-byte Buffer instance');
}

this.#secret = Uint8Array.prototype.slice.call(secret);
this.#counter = Math.floor(Date.now() / 1000 / STEP);

[this.#prevprev, this.#prev, this.#now, this.#next, this.#nextnext] = [
this.#counter - 2,
this.#counter - 1,
this.#counter,
this.#counter + 1,
this.#counter++ + 2,
].map(compute.bind(undefined, this.#secret));

setInterval(() => {
[
this.#prevprev,
this.#prev,
this.#now,
this.#next,
this.#nextnext,
] = [
this.#prev,
this.#now,
this.#next,
this.#nextnext,
compute(this.#secret, this.#counter++ + 2),
];
}, STEP * 1000).unref();
}

nextNonce() {
return this.#next;
}

checkNonce(nonce) {
let result = 0;

for (const server of [this.#prevprev, this.#prev, this.#now, this.#next, this.#nextnext]) {
result ^= compare(server, nonce);
}

return result === 0;
}
};
1 change: 1 addition & 0 deletions lib/helpers/errors.js
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,7 @@ const classes = [
['unauthorized_client'],
['unknown_user_id'],
['unsupported_grant_type', 'unsupported grant_type requested'],
['use_dpop_nonce'],
['unsupported_response_mode', 'unsupported response_mode requested'],
['unsupported_response_type', 'unsupported response_type requested'],
];
Expand Down
16 changes: 10 additions & 6 deletions lib/helpers/initialize_app.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,16 +21,20 @@ const attention = require('./attention');
const { ROUTER_URL_METHOD } = require('./symbols');

const discoveryRoute = '/.well-known/openid-configuration';
const CORS_AUTHORIZATION = { exposeHeaders: 'WWW-Authenticate', maxAge: 3600 };
const CORS = {
open: cors({ allowMethods: 'GET', maxAge: 3600 }),
userinfo: cors({ allowMethods: 'GET,POST', clientBased: true, ...CORS_AUTHORIZATION }),
client: cors({ allowMethods: 'POST', clientBased: true, ...CORS_AUTHORIZATION }),
};

module.exports = function initializeApp() {
const configuration = instance(this).configuration();

const CORS_AUTHORIZATION = { exposeHeaders: ['WWW-Authenticate'], maxAge: 3600 };
if (configuration.features.dPoP.nonceSecret) {
CORS_AUTHORIZATION.exposeHeaders.push('DPoP-Nonce');
}
const CORS = {
open: cors({ allowMethods: 'GET', maxAge: 3600 }),
userinfo: cors({ allowMethods: 'GET,POST', clientBased: true, ...CORS_AUTHORIZATION }),
client: cors({ allowMethods: 'POST', clientBased: true, ...CORS_AUTHORIZATION }),
};

const router = new Router();
instance(this).router = router;

Expand Down
37 changes: 32 additions & 5 deletions lib/helpers/validate_dpop.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ const {
calculateJwkThumbprint,
} = require('jose');

const { InvalidDpopProof } = require('./errors');
const { InvalidDpopProof, UseDpopNonce } = require('./errors');
const instance = require('./weak_cache');
const base64url = require('./base64url');
const epochTime = require('./epoch_time');
Expand All @@ -28,6 +28,19 @@ module.exports = async (ctx, accessToken) => {
return undefined;
}

const { DPoPNonces } = instance(ctx.oidc.provider);

const requireNonce = dPoPConfig.requireNonce(ctx);
if (typeof requireNonce !== 'boolean') {
throw new Error('features.dPoP.requireNonce must return a boolean');
}

if (DPoPNonces) {
ctx.set('DPoP-Nonce', DPoPNonces.nextNonce());
} else if (requireNonce) {
throw new Error('features.dPoP.nonceSecret configuration is missing');
}

let payload;
let protectedHeader;
try {
Expand All @@ -41,10 +54,16 @@ module.exports = async (ctx, accessToken) => {
throw new InvalidDpopProof('DPoP proof must have a jti string property');
}

const now = epochTime();
const diff = Math.abs(now - payload.iat);
if (diff > 60) {
throw new InvalidDpopProof('DPoP proof iat is not recent enough');
if (payload.nonce !== undefined && typeof payload.nonce !== 'string') {
throw new InvalidDpopProof('DPoP proof nonce must be a string');
}

if (!payload.nonce) {
const now = epochTime();
const diff = Math.abs(now - payload.iat);
if (diff > 300) {
throw new InvalidDpopProof('DPoP proof iat is not recent enough');
}
}

if (payload.htm !== ctx.method) {
Expand Down Expand Up @@ -78,6 +97,14 @@ module.exports = async (ctx, accessToken) => {
throw new InvalidDpopProof('invalid DPoP key binding', err.message);
}

if (!payload.nonce && requireNonce) {
throw new UseDpopNonce('nonce is required in the DPoP proof');
}

if (payload.nonce && (!DPoPNonces || !DPoPNonces.checkNonce(payload.nonce))) {
throw new UseDpopNonce('invalid nonce in DPoP proof');
}

const thumbprint = await calculateJwkThumbprint(protectedHeader.jwk);

return { thumbprint, jti: payload.jti, iat: payload.iat };
Expand Down
5 changes: 5 additions & 0 deletions lib/provider.js
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ const { SessionNotFound, OIDCProviderError } = require('./helpers/errors');
const models = require('./models');
const ssHandler = require('./helpers/samesite_handler');
const get = require('./helpers/_/get');
const DPoPNonces = require('./helpers/dpop_nonces');

function assertReqRes(req, res) {
assert(
Expand Down Expand Up @@ -146,6 +147,10 @@ class Provider extends events.EventEmitter {
attention.warn('configuration cookies.keys is missing, this option is critical to detect and ignore tampered cookies');
}

if (configuration.features.dPoP.nonceSecret !== undefined) {
instance(this).DPoPNonces = new DPoPNonces(configuration.features.dPoP.nonceSecret);
}

instance(this).responseModes = new Map();
instance(this).grantTypeHandlers = new Map();
instance(this).grantTypeDupes = new Map();
Expand Down
Loading

0 comments on commit 8d82988

Please sign in to comment.