Skip to content

Commit

Permalink
Add authorized clients tests.
Browse files Browse the repository at this point in the history
  • Loading branch information
dlongley committed Jan 26, 2025
1 parent b47f891 commit 2d0d9ee
Show file tree
Hide file tree
Showing 5 changed files with 212 additions and 53 deletions.
10 changes: 5 additions & 5 deletions lib/config.js
Original file line number Diff line number Diff line change
Expand Up @@ -29,14 +29,14 @@ cfg.authorization = {
clients: {
/*
<pet name of client>: {
client_id: ...,
id: ...,
// scopes that can be requested in the future; changing this DOES NOT
// alter existing access (for already issued tokens)
requestableScopes: ...,
// a SHA-256 of the client ID's password; security depends on passwords
// being sufficiently large (16 bytes or more) random strings; this
// field should be populated using an appropriate cloud secret store
// in any deployment
// base64url-encoding of a SHA-256 of the client ID's password;
// security depends on passwords being sufficiently large (16 bytes or
// more) random strings; this field should be populated using an
// appropriate cloud secret store in any deployment
passwordHash
}
*/
Expand Down
74 changes: 49 additions & 25 deletions lib/http/oauth2.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
} from '@bedrock/oauth2-verifier';
import {createHash, timingSafeEqual} from 'node:crypto';
import {importJWK, SignJWT} from 'jose';
import assert from 'assert-plus';
import {asyncHandler} from '@bedrock/express';
import bodyParser from 'body-parser';
import cors from 'cors';
Expand All @@ -22,14 +23,18 @@ import {createValidateMiddleware as validate} from '@bedrock/validation';

const {util: {BedrockError}} = bedrock;

// initialize oauth issuer info; export for testing purposes only
// export for testing purposes only
export let OAUTH2_ISSUER;

const CLIENT_MAP = new Map();

// initialize oauth info
bedrock.events.on('bedrock.init', async () => {
// use application identity zcap key for capabilities expressed as
// oauth access tokens as well
const {id, keys: {capabilityInvocationKey}} = getAppIdentity();
const cfg = bedrock.config[NAMESPACE];
const {routes} = cfg.authorization.oauth2;
const {clients, routes} = cfg.authorization.oauth2;

const {baseUri} = bedrock.config.server;
OAUTH2_ISSUER = {
Expand Down Expand Up @@ -75,6 +80,13 @@ bedrock.events.on('bedrock.init', async () => {
cause: e
});
}

// build map of client_id => client from named clients
for(const clientName in clients) {
const client = clients[clientName];
_assertOAuth2Client({client});
CLIENT_MAP.set(client.id, client);
}
});

export function addOAuth2AuthzServer({app}) {
Expand Down Expand Up @@ -132,6 +144,23 @@ export async function checkAccessToken({req, getExpectedValues} = {}) {
});
}

function _assertOAuth2Client({client} = {}) {
// do not use assert on whole object to avoid logging client password
if(!(client && typeof client === 'object')) {
throw new TypeError(
'Invalid oauth2 client configuration; client is not an object.');
}
assert.string(client.id, 'client.id');
assert.arrayOfString(client.requestableScopes, 'client.requestableScopes');
if(!(typeof client.passwordHash === 'string' &&
Buffer.from(client.passwordHash, 'base64url').length === 32)) {
throw new TypeError(
'Invalid oauth2 client configuration; ' +
'"passwordHash" must be a base64url-encoded SHA-256 hash of the ' +
`client's sufficiently large, random password.`);
}
}

async function _assertOauth2ClientPassword({client, password}) {
// hash password for comparison (fast hash is used here which presumes
// passwords are large and random so no rainbow table can be built but
Expand All @@ -140,8 +169,7 @@ async function _assertOauth2ClientPassword({client, password}) {

// ensure given password hash matches client record
if(!timingSafeEqual(
Buffer.from(client.passwordHash, 'utf8'),
Buffer.from(passwordHash, 'utf8'))) {
Buffer.from(client.passwordHash, 'base64url'), passwordHash)) {
throw new BedrockError(
'Invalid OAuth2 client password.', {
name: 'NotAllowedError',
Expand Down Expand Up @@ -188,20 +216,19 @@ async function _checkBasicAuthorization({req}) {
// export for testing purposes only
export async function _createAccessToken({client, request}) {
// get (and validate) requested scopes
const scope = _getRequestedScopes({client, request});
const scope = _getRequestedScopes({client, request}).join(' ');

// set `exp` based on configured TTL
const cfg = bedrock.config[NAMESPACE];
const {accessTokens} = cfg.authorization.oauth2;
const exp = Math.floor(Date.getTime() / 1000) + accessTokens.ttl;
const exp = Math.floor(Date.now() / 1000) + accessTokens.ttl;

// create access token
const {
id: iss,
issuer: iss,
keyPair: {privateKey, publicKeyJwk: {alg, kid}}
} = OAUTH2_ISSUER;
const {basePath} = cfg.routes;
const audience = `${bedrock.config.server.baseUri}${basePath}`;
const audience = bedrock.config.server.baseUri;
const {accessToken, ttl} = await _createOAuth2AccessToken({
privateKey, alg, kid, audience, scope, exp, iss
});
Expand Down Expand Up @@ -233,25 +260,22 @@ export async function _createOAuth2AccessToken({
}

function _getOAuth2Client({clientId}) {
// FIXME: get from config
return {
id: clientId,
requestableScopes: ['write:/some/path'],
passwordHash: ''
};
// FIXME: throw if `clientId` is not found
throw new BedrockError(
`OAuth2 client "${clientId}" not found.`, {
name: 'NotFoundError',
details: {
httpStatusCode: 404,
public: true
}
});
const client = CLIENT_MAP.get(clientId);
if(!client) {
throw new BedrockError(
`OAuth2 client "${clientId}" not found.`, {
name: 'NotFoundError',
details: {
httpStatusCode: 404,
public: true
}
});
}
return client;
}

function _getRequestedScopes({client, request}) {
const scopes = request.scope.split(' ');
const scopes = [...new Set(request.scope.split(' '))];
for(const scope of scopes) {
if(!client.requestableScopes.includes(scope)) {
throw new BedrockError(
Expand Down
124 changes: 113 additions & 11 deletions test/mocha/10-api.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,9 @@ import {CapabilityAgent} from '@digitalbazaar/webkms-client';
import {zcapClient} from '@bedrock/basic-authz-server';

describe('http API', () => {
const target = '/test-authorize-request';
describe('authz request middleware', () => {
let capability;
const target = '/test-authorize-request';
let unauthorizedZcapClient;
let url;
before(async () => {
Expand All @@ -23,9 +23,10 @@ describe('http API', () => {
url = `${rootInvocationTarget}${target}`;
capability = `urn:zcap:root:${encodeURIComponent(rootInvocationTarget)}`;
});

const fixtures = [{
name: 'GET',
expectedAction: 'get',
expectedAction: 'read',
async authorizedZcap() {
const result = await zcapClient.read({url, capability});
return result.data;
Expand Down Expand Up @@ -60,8 +61,6 @@ describe('http API', () => {
return result.data;
}
}];
// FIXME: remove me
fixtures.shift();
for(const fixture of fixtures) {
describe(fixture.name, () => {
it('succeeds using an authorized zcap', async () => {
Expand Down Expand Up @@ -90,7 +89,7 @@ describe('http API', () => {
err.data.type.should.equal('NotAllowedError');
});
it('succeeds using authorized access token', async () => {
const accessToken = await helpers.getOAuth2AccessToken({
const accessToken = await helpers.createOAuth2AccessToken({
action: fixture.expectedAction, target
});
let err;
Expand All @@ -105,7 +104,7 @@ describe('http API', () => {
result.should.deep.equal({success: true});
});
it('fails using an expired access token', async () => {
const accessToken = await helpers.getOAuth2AccessToken({
const accessToken = await helpers.createOAuth2AccessToken({
action: fixture.expectedAction, target,
// expired 10 minutes ago
exp: Math.floor(Date.now() / 1000 - 600)
Expand All @@ -129,7 +128,7 @@ describe('http API', () => {
err.data.cause.details.claim.should.equal('exp');
});
it('fails using an access token w/future "nbf" claim', async () => {
const accessToken = await helpers.getOAuth2AccessToken({
const accessToken = await helpers.createOAuth2AccessToken({
action: fixture.expectedAction, target,
// 10 minutes from now
nbf: Math.floor(Date.now() / 1000 + 600)
Expand All @@ -154,7 +153,7 @@ describe('http API', () => {
err.data.cause.details.claim.should.equal('nbf');
});
it('fails using an access token w/bad "typ" claim', async () => {
const accessToken = await helpers.getOAuth2AccessToken({
const accessToken = await helpers.createOAuth2AccessToken({
action: fixture.expectedAction, target,
typ: 'unexpected'
});
Expand All @@ -178,7 +177,7 @@ describe('http API', () => {
err.data.cause.details.claim.should.equal('typ');
});
it('fails using an access token w/bad "iss" claim', async () => {
const accessToken = await helpers.getOAuth2AccessToken({
const accessToken = await helpers.createOAuth2AccessToken({
action: fixture.expectedAction, target,
iss: 'urn:example:unexpected'
});
Expand All @@ -202,7 +201,7 @@ describe('http API', () => {
err.data.cause.details.claim.should.equal('iss');
});
it('fails using an access token w/bad action', async () => {
const accessToken = await helpers.getOAuth2AccessToken({
const accessToken = await helpers.createOAuth2AccessToken({
action: 'incorrect', target
});
let err;
Expand All @@ -225,7 +224,7 @@ describe('http API', () => {
err.data.cause.details.claim.should.equal('scope');
});
it('fails using an access token w/bad target', async () => {
const accessToken = await helpers.getOAuth2AccessToken({
const accessToken = await helpers.createOAuth2AccessToken({
action: fixture.expectedAction, target: '/foo'
});
let err;
Expand All @@ -250,4 +249,107 @@ describe('http API', () => {
});
}
});

describe('request oauth2 access token', () => {
let clients;
let url;
before(() => {
({clients} = bedrock.config['basic-authz-server'].authorization.oauth2);
url = `${bedrock.config.server.baseUri}/openid/token`;
});

it('succeeds when requesting one authorized scope', async () => {
let err;
let result;
try {
result = await helpers.requestOAuth2AccessToken({
url,
clientId: clients.authorizedClient.id,
password: clients.authorizedClient.id,
requestedScopes: [`read:${target}`]
});
} catch(e) {
err = e;
}
assertNoError(err);
should.exist(result);
result.data.access_token.should.be.a('string');
});
it('succeeds when requesting all authorized scopes', async () => {
let err;
let result;
try {
result = await helpers.requestOAuth2AccessToken({
url,
clientId: clients.authorizedClient.id,
password: clients.authorizedClient.id,
requestedScopes: [`read:${target}`, `write:${target}`]
});
} catch(e) {
err = e;
}
assertNoError(err);
should.exist(result);
result.data.access_token.should.be.a('string');
});
it('fails when requesting an unauthorized scope', async () => {
let err;
let result;
try {
result = await helpers.requestOAuth2AccessToken({
url,
clientId: clients.authorizedClient.id,
password: clients.authorizedClient.id,
requestedScopes: [`read:/`]
});
} catch(e) {
err = e;
}
should.exist(err);
should.not.exist(result);
err.status.should.equal(403);
err.data.error.should.equal('not_allowed_error');
});
it('fails when requesting and no scopes are authorized', async () => {
let err;
let result;
try {
result = await helpers.requestOAuth2AccessToken({
url,
clientId: clients.unauthorizedClient.id,
password: clients.unauthorizedClient.id,
requestedScopes: [`read:${target}`]
});
} catch(e) {
err = e;
}
should.exist(err);
should.not.exist(result);
err.status.should.equal(403);
err.data.error.should.equal('not_allowed_error');
});
it('succeeds when using requested token', async () => {
const {
data: {access_token: accessToken}
} = await helpers.requestOAuth2AccessToken({
url,
clientId: clients.authorizedClient.id,
password: clients.authorizedClient.id,
requestedScopes: [`read:${target}`]
});
let err;
let result;
try {
result = await helpers.doOAuth2Request({
url: `${bedrock.config.server.baseUri}${target}`,
accessToken
});
} catch(e) {
err = e;
}
assertNoError(err);
should.exist(result);
result.data.should.deep.equal({success: true});
});
});
});
Loading

0 comments on commit 2d0d9ee

Please sign in to comment.