From 2d0d9ee5941cea76f09a2a6b8f4a6ce0863e0c5f Mon Sep 17 00:00:00 2001 From: Dave Longley Date: Sun, 26 Jan 2025 17:52:21 -0500 Subject: [PATCH] Add authorized clients tests. --- lib/config.js | 10 ++-- lib/http/oauth2.js | 74 ++++++++++++++++--------- test/mocha/10-api.js | 124 ++++++++++++++++++++++++++++++++++++++---- test/mocha/helpers.js | 39 +++++++++---- test/test.config.js | 18 ++++++ 5 files changed, 212 insertions(+), 53 deletions(-) diff --git a/lib/config.js b/lib/config.js index 19071be..2fccd57 100644 --- a/lib/config.js +++ b/lib/config.js @@ -29,14 +29,14 @@ cfg.authorization = { clients: { /* : { - 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 } */ diff --git a/lib/http/oauth2.js b/lib/http/oauth2.js index 42fa03f..87d3012 100644 --- a/lib/http/oauth2.js +++ b/lib/http/oauth2.js @@ -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'; @@ -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 = { @@ -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}) { @@ -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 @@ -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', @@ -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 }); @@ -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( diff --git a/test/mocha/10-api.js b/test/mocha/10-api.js index 8bdda90..1b0027f 100644 --- a/test/mocha/10-api.js +++ b/test/mocha/10-api.js @@ -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 () => { @@ -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; @@ -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 () => { @@ -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; @@ -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) @@ -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) @@ -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' }); @@ -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' }); @@ -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; @@ -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; @@ -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}); + }); + }); }); diff --git a/test/mocha/helpers.js b/test/mocha/helpers.js index c6858bb..56450be 100644 --- a/test/mocha/helpers.js +++ b/test/mocha/helpers.js @@ -10,6 +10,22 @@ import {httpClient} from '@digitalbazaar/http-client'; import {httpsAgent} from '@bedrock/https-agent'; import {ZcapClient} from '@digitalbazaar/ezcap'; +export async function createOAuth2AccessToken({ + action, target, audience, exp, iss, nbf, typ = 'at+jwt' +}) { + const { + issuer, + keyPair: {privateKey, publicKeyJwk: {alg, kid}} + } = OAUTH2_ISSUER; + audience = audience ?? bedrock.config.server.baseUri; + iss = iss ?? issuer; + const scope = `${action}:${target}`; + const {accessToken} = await _createOAuth2AccessToken({ + privateKey, alg, kid, audience, scope, exp, iss, nbf, typ + }); + return accessToken; +} + export function createZcapClient({ capabilityAgent, delegationSigner, invocationSigner }) { @@ -45,18 +61,17 @@ export async function doOAuth2Request({url, json, accessToken}) { }); } -export async function getOAuth2AccessToken({ - action, target, audience, exp, iss, nbf, typ = 'at+jwt' +export async function requestOAuth2AccessToken({ + url, clientId, password, requestedScopes }) { - const { - issuer, - keyPair: {privateKey, publicKeyJwk: {alg, kid}} - } = OAUTH2_ISSUER; - audience = audience ?? bedrock.config.server.baseUri; - iss = iss ?? issuer; - const scope = `${action}:${target}`; - const {accessToken} = await _createOAuth2AccessToken({ - privateKey, alg, kid, audience, scope, exp, iss, nbf, typ + const body = new URLSearchParams({ + grant_type: 'client_credentials', + scope: requestedScopes.join(' ') }); - return accessToken; + const credentials = Buffer.from(`${clientId}:${password}`).toString('base64'); + const headers = { + accept: 'application/json', + authorization: `Basic ${credentials}` + }; + return httpClient.post(url, {agent: httpsAgent, body, headers}); } diff --git a/test/test.config.js b/test/test.config.js index 3b9b24d..c7e12c9 100644 --- a/test/test.config.js +++ b/test/test.config.js @@ -7,6 +7,7 @@ import path from 'node:path'; import '@bedrock/app-identity'; import '@bedrock/did-io'; import '@bedrock/https-agent'; +import '@bedrock/basic-authz-server'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); config.mocha.options.fullTrace = true; @@ -17,3 +18,20 @@ config['https-agent'].rejectUnauthorized = false; // disable veres one fetching config['did-io'].methodOverrides.v1.disableFetch = true; + +const clients = config['basic-authz-server'].authorization.oauth2.clients; +clients.authorizedClient = { + id: 'cbd47e49-8450-43f6-a3ce-072d876e7f62', + requestableScopes: [ + 'read:/test-authorize-request', + 'write:/test-authorize-request' + ], + passwordHash: 'qpMmqCHdQ0FkyVCF1Sfuprt4jKZ4p4Id1LhSLxmdmu8' +}; +clients.unauthorizedClient = { + id: '5165774d-fadc-484b-8a78-d2b049721b52', + // no requestable scopes + requestableScopes: [], + // hash of `client_id` + passwordHash: 'JySRI3hb_DJ3rV4oUulOowEcLkRS4DCMdnfzJx57Z3g' +};