Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -1219,6 +1219,9 @@ Helper function used to determine whether the client/RS (client argument) is all
_**default value**_:
```js
async function introspectionAllowedPolicy(ctx, client, token) {
// @param ctx - koa request context
// @param client - authenticated client making the request
// @param token - token being introspected
if (
client.clientAuthMethod === 'none'
&& token.clientId !== ctx.oidc.client.clientId
Expand Down Expand Up @@ -1852,10 +1855,38 @@ Enables Token Revocation for:
_**default value**_:
```js
{
allowedPolicy: [AsyncFunction: revocationAllowedPolicy], // see expanded details below
enabled: false
}
```

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


#### allowedPolicy

Helper function used to determine whether the client/RS (client argument) is allowed to revoke the given token (token argument).


_**default value**_:
```js
async function revocationAllowedPolicy(ctx, client, token) {
// @param ctx - koa request context
// @param client - authenticated client making the request
// @param token - token being revoked
if (token.clientId !== client.clientId) {
if (client.clientAuthMethod === 'none') {
// do not revoke but respond as success to disallow guessing valid tokens
return false;
}
throw new errors.InvalidRequest('client is not authorized to revoke the presented token');
}
return true;
}
```

</details>

### features.richAuthorizationRequests

[`RFC9396`](https://www.rfc-editor.org/rfc/rfc9396.html) - OAuth 2.0 Rich Authorization Requests
Expand Down
41 changes: 15 additions & 26 deletions lib/actions/introspection.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { urlencoded as parseBody } from '../shared/selective_body.js';
import rejectDupes from '../shared/reject_dupes.js';
import paramsMiddleware from '../shared/assemble_params.js';
import { InvalidRequest } from '../helpers/errors.js';
import rejectStructuredTokens from '../shared/reject_structured_tokens.js';

const introspectable = new Set(['AccessToken', 'ClientCredentials', 'RefreshToken']);
const JWT = 'application/token-introspection+jwt';
Expand Down Expand Up @@ -61,6 +62,8 @@ export default function introspectionAction(provider) {
await next();
},

rejectStructuredTokens,

async function jwtIntrospectionResponse(ctx, next) {
if (jwtIntrospection.enabled) {
const { client } = ctx.oidc;
Expand Down Expand Up @@ -98,37 +101,23 @@ export default function introspectionAction(provider) {
ctx.body = { active: false };

let token;

switch (params.token_type_hint) {
case 'access_token':
token = await getAccessToken(params.token)
.then((result) => {
if (result) return result;
return Promise.all([
getClientCredentials(params.token),
getRefreshToken(params.token),
]).then(findResult);
});
break;
case 'client_credentials':
token = await getClientCredentials(params.token)
.then((result) => {
if (result) return result;
return Promise.all([
getAccessToken(params.token),
getRefreshToken(params.token),
]).then(findResult);
});
case 'urn:ietf:params:oauth:token-type:access_token':
token = await Promise.all([
getAccessToken(params.token),
getClientCredentials(params.token),
])
.then(findResult)
.then((result) => result || getRefreshToken(params.token));
break;
case 'refresh_token':
case 'urn:ietf:params:oauth:token-type:refresh_token':
token = await getRefreshToken(params.token)
.then((result) => {
if (result) return result;
return Promise.all([
getAccessToken(params.token),
getClientCredentials(params.token),
]).then(findResult);
});
.then((result) => result || Promise.all([
getAccessToken(params.token),
getClientCredentials(params.token),
]).then(findResult));
break;
default:
token = await Promise.all([
Expand Down
52 changes: 22 additions & 30 deletions lib/actions/revocation.js
Original file line number Diff line number Diff line change
@@ -1,18 +1,23 @@
import { InvalidRequest } from '../helpers/errors.js';
import presence from '../helpers/validate_presence.js';
import instance from '../helpers/weak_cache.js';
import getClientAuth from '../shared/client_auth.js';
import { urlencoded as parseBody } from '../shared/selective_body.js';
import rejectDupes from '../shared/reject_dupes.js';
import paramsMiddleware from '../shared/assemble_params.js';
import rejectStructuredTokens from '../shared/reject_structured_tokens.js';
import revoke from '../helpers/revoke.js';

const revokeable = new Set(['AccessToken', 'ClientCredentials', 'RefreshToken']);

export default function revocationAction(provider) {
const { params: authParams, middleware: clientAuth } = getClientAuth(provider);
const PARAM_LIST = new Set(['token', 'token_type_hint', ...authParams]);
const { grantTypeHandlers } = instance(provider);
const { grantTypeHandlers, configuration } = instance(provider);
const {
features: {
revocation: { allowedPolicy },
},
} = configuration;

function getAccessToken(token) {
return provider.AccessToken.find(token);
Expand Down Expand Up @@ -47,48 +52,35 @@ export default function revocationAction(provider) {
await next();
},

rejectStructuredTokens,

async function renderTokenResponse(ctx, next) {
ctx.status = 200;
ctx.body = '';
await next();
},

async function revokeToken(ctx) {
let token;
const { params } = ctx.oidc;

let token;
switch (params.token_type_hint) {
case 'access_token':
case 'urn:ietf:params:oauth:token-type:access_token':
token = await getAccessToken(params.token)
.then((result) => {
if (result) return result;
return Promise.all([
getClientCredentials(params.token),
getRefreshToken(params.token),
]).then(findResult);
});
break;
case 'client_credentials':
token = await getClientCredentials(params.token)
.then((result) => {
if (result) return result;
return Promise.all([
getAccessToken(params.token),
getRefreshToken(params.token),
]).then(findResult);
});
token = await Promise.all([
getAccessToken(params.token),
getClientCredentials(params.token),
])
.then(findResult)
.then((result) => result || getRefreshToken(params.token));
break;
case 'refresh_token':
case 'urn:ietf:params:oauth:token-type:refresh_token':
token = await getRefreshToken(params.token)
.then((result) => {
if (result) return result;
return Promise.all([
getAccessToken(params.token),
getClientCredentials(params.token),
]).then(findResult);
});
.then((result) => result || Promise.all([
getAccessToken(params.token),
getClientCredentials(params.token),
]).then(findResult));
break;
default:
token = await Promise.all([
Expand All @@ -106,8 +98,8 @@ export default function revocationAction(provider) {
return;
}

if (token.clientId !== ctx.oidc.client.clientId) {
throw new InvalidRequest('this token does not belong to you');
if (!(await allowedPolicy(ctx, ctx.oidc.client, token))) {
return;
}

await token.destroy();
Expand Down
33 changes: 32 additions & 1 deletion lib/helpers/defaults.js
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,9 @@ async function successSource(ctx) {
}

async function introspectionAllowedPolicy(ctx, client, token) {
// @param ctx - koa request context
// @param client - authenticated client making the request
// @param token - token being introspected
shouldChange('features.introspection.allowedPolicy', 'to check whether the caller is authorized to receive the introspection response');

if (
Expand All @@ -182,6 +185,24 @@ async function introspectionAllowedPolicy(ctx, client, token) {
return true;
}

async function revocationAllowedPolicy(ctx, client, token) {
// @param ctx - koa request context
// @param client - authenticated client making the request
// @param token - token being revoked
shouldChange('features.revocation.allowedPolicy', 'to check whether the caller is authorized to revoke the token');

if (token.clientId !== client.clientId) {
if (client.clientAuthMethod === 'none') {
// do not revoke but respond as success to disallow guessing valid tokens
return false;
}

throw new errors.InvalidRequest('client is not authorized to revoke the presented token');
}

return true;
}

function idFactory(ctx) {
return nanoid();
}
Expand Down Expand Up @@ -1926,7 +1947,17 @@ function makeDefaults() {
* - refresh tokens
*
*/
revocation: { enabled: false },
revocation: {
enabled: false,

/*
* features.revocation.allowedPolicy
*
* description: Helper function used to determine whether the client/RS (client argument)
* is allowed to revoke the given token (token argument).
*/
allowedPolicy: revocationAllowedPolicy,
},

/*
* features.userinfo
Expand Down
1 change: 1 addition & 0 deletions lib/helpers/errors.js
Original file line number Diff line number Diff line change
Expand Up @@ -156,3 +156,4 @@ export const UnsupportedGrantType = E('unsupported_grant_type', 'unsupported gra
export const UnsupportedResponseMode = E('unsupported_response_mode', 'unsupported response_mode requested');
export const UnsupportedResponseType = E('unsupported_response_type', 'unsupported response_type requested');
export const UseDpopNonce = E('use_dpop_nonce');
export const UnsupportedTokenType = E('unsupported_token_type');
18 changes: 18 additions & 0 deletions lib/shared/reject_structured_tokens.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { decodeProtectedHeader } from 'jose';

import { UnsupportedTokenType } from '../helpers/errors.js';

export default async function rejectStructuredTokens(ctx, next) {
const { params } = ctx.oidc;

let tokenIsJWT;
try {
tokenIsJWT = !!decodeProtectedHeader(params.token);
} catch {}

if (tokenIsJWT) {
throw new UnsupportedTokenType(`Structured JWT Tokens cannot be ${ctx.oidc.route === 'revocation' ? 'revoked' : 'introspected'} via the ${ctx.oidc.route}_endpoint`);
}

return next();
}
25 changes: 10 additions & 15 deletions test/introspection/introspection.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -165,7 +165,7 @@ describe('introspection features', () => {
const token = await rt.save();
return this.agent.post(route)
.auth('client', 'secret')
.send({ token, token_type_hint: 'client_credentials' })
.send({ token, token_type_hint: 'access_token' })
.type('form')
.expect(200)
.expect((response) => {
Expand Down Expand Up @@ -216,44 +216,39 @@ describe('introspection features', () => {
const token = await rt.save();
return this.agent.post(route)
.auth('client', 'secret')
.send({ token, token_type_hint: 'client_credentials' })
.send({ token, token_type_hint: 'access_token' })
.type('form')
.expect(200)
.expect((response) => {
expect(response.body).to.contain.keys('client_id');
});
});

it('returns the properties for client credentials token [wrong hint]', async function () {
it('returns the properties for client credentials token [unrecognized hint]', async function () {
const rt = new this.provider.ClientCredentials({
client: await this.provider.Client.find('client'),
});

const token = await rt.save();
return this.agent.post(route)
.auth('client', 'secret')
.send({ token, token_type_hint: 'access_token' })
.send({ token, token_type_hint: 'foobar' })
.type('form')
.expect(200)
.expect((response) => {
expect(response.body).to.contain.keys('client_id');
});
});

it('returns the properties for client credentials token [unrecognized hint]', async function () {
const rt = new this.provider.ClientCredentials({
client: await this.provider.Client.find('client'),
});

const token = await rt.save();
it('rejects structured tokens', function () {
return this.agent.post(route)
.auth('client', 'secret')
.send({ token, token_type_hint: 'foobar' })
.send({
token: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWV9.TJVA95OrM7E2cBab30RMHrHDcEfxjoYZgeFONFh7HgQ',
})
.type('form')
.expect(200)
.expect((response) => {
expect(response.body).to.contain.keys('client_id');
});
.expect(400)
.expect({ error: 'unsupported_token_type', error_description: 'Structured JWT Tokens cannot be introspected via the introspection_endpoint' });
});

it('can be called by pairwise clients', async function () {
Expand Down
5 changes: 5 additions & 0 deletions test/revocation/revocation.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,5 +19,10 @@ export default {
client_secret: 'secret',
redirect_uris: ['https://client2.example.com/cb'],
},
{
client_id: 'client-public',
token_endpoint_auth_method: 'none',
redirect_uris: ['https://client-public.example.com/cb'],
},
],
};
Loading