From 7090519a5ce4f0b3e6198c772a77405d45cb03c3 Mon Sep 17 00:00:00 2001 From: Dave Longley Date: Mon, 27 Jan 2025 15:00:13 -0500 Subject: [PATCH] Use `secretHash`, deprecate `passwordHash`, use `grant_types_supported`. --- CHANGELOG.md | 6 ++++ lib/config.js | 6 ++-- lib/http/oauth2.js | 70 ++++++++++++++++++++++++------------------- test/mocha/10-api.js | 10 +++---- test/mocha/helpers.js | 4 +-- test/test.config.js | 4 +-- 6 files changed, 57 insertions(+), 43 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 99950a3..29a2fd0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # bedrock-basic-authz-server ChangeLog +## 1.1.0 - 2025-01-dd + +### Changed +- Use `secretHash` instead of `passwordHash` (now deprecated but still + available for use) in oauth2 client configuration. + ## 1.0.0 - 2025-01-26 - See git history for changes. diff --git a/lib/config.js b/lib/config.js index 2fccd57..1dad771 100644 --- a/lib/config.js +++ b/lib/config.js @@ -33,11 +33,11 @@ cfg.authorization = { // scopes that can be requested in the future; changing this DOES NOT // alter existing access (for already issued tokens) requestableScopes: ..., - // base64url-encoding of a SHA-256 of the client ID's password; - // security depends on passwords being sufficiently large (16 bytes or + // base64url-encoding of a SHA-256 of the client ID's secret; + // security depends on secrets being sufficiently large (16 bytes or // more) random strings; this field should be populated using an // appropriate cloud secret store in any deployment - passwordHash + secretHash } */ }, diff --git a/lib/http/oauth2.js b/lib/http/oauth2.js index 87d3012..cd4e538 100644 --- a/lib/http/oauth2.js +++ b/lib/http/oauth2.js @@ -48,7 +48,8 @@ bedrock.events.on('bedrock.init', async () => { config: { issuer: baseUri, jwks_uri: `${baseUri}${routes.jwks}`, - token_endpoint: `${baseUri}${routes.token}` + token_endpoint: `${baseUri}${routes.token}`, + grant_types_supported: ['client_credentials'] } }; @@ -83,8 +84,7 @@ bedrock.events.on('bedrock.init', async () => { // build map of client_id => client from named clients for(const clientName in clients) { - const client = clients[clientName]; - _assertOAuth2Client({client}); + const client = _importOAuth2Client({client: clients[clientName]}); CLIENT_MAP.set(client.id, client); } }); @@ -144,34 +144,17 @@ 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 - // the passwords won't be stored directly) - const passwordHash = await _sha256(password); +async function _assertOauth2ClientSecret({client, secret}) { + // hash secret for comparison (fast hash is used here which presumes + // secrets are large and random so no rainbow table can be built but + // the secrets won't be stored directly) + const secretHash = await _sha256(secret); - // ensure given password hash matches client record + // ensure given secret hash matches client record if(!timingSafeEqual( - Buffer.from(client.passwordHash, 'base64url'), passwordHash)) { + Buffer.from(client.secretHash, 'base64url'), secretHash)) { throw new BedrockError( - 'Invalid OAuth2 client password.', { + 'Invalid OAuth2 client secret.', { name: 'NotAllowedError', details: { httpStatusCode: 403, @@ -190,14 +173,14 @@ async function _checkBasicAuthorization({req}) { // parse credentials // see: https://datatracker.ietf.org/doc/html/rfc7617#section-2 const { - credentials: {userId: clientId, password} + credentials: {userId: clientId, password: secret} } = getBasicAuthorizationCredentials({req}); // find matching client const client = await _getOAuth2Client({clientId}); - // assert password - await _assertOauth2ClientPassword({client, password}); + // assert secret + await _assertOauth2ClientSecret({client, secret}); return {client}; } catch(cause) { @@ -291,6 +274,31 @@ function _getRequestedScopes({client, request}) { return scopes; } +function _importOAuth2Client({client} = {}) { + // do not use assert on whole object to avoid logging client secret + if(!(client && typeof client === 'object')) { + throw new TypeError( + 'Invalid oauth2 client configuration; client is not an object.'); + } + const {id, requestableScopes} = client; + assert.string(id, 'client.id'); + assert.arrayOfString(requestableScopes, 'client.requestableScopes'); + const secretHash = client.secretHash ?? client.passwordHash; + if(!(typeof secretHash === 'string' && + Buffer.from(secretHash, 'base64url').length === 32)) { + throw new TypeError( + 'Invalid oauth2 client configuration; ' + + '"secretHash" (or deprecated "passwordHash") must be a ' + + 'base64url-encoded SHA-256 hash of the ' + + `client's sufficiently large, random secret.`); + } + return { + id, + requestableScopes, + secretHash + }; +} + async function _processAccessTokenRequest({req}) { // only "client_credentials" grant type is supported // see: https://datatracker.ietf.org/doc/html/rfc6749#section-4.4 diff --git a/test/mocha/10-api.js b/test/mocha/10-api.js index 1b0027f..acd57b3 100644 --- a/test/mocha/10-api.js +++ b/test/mocha/10-api.js @@ -265,7 +265,7 @@ describe('http API', () => { result = await helpers.requestOAuth2AccessToken({ url, clientId: clients.authorizedClient.id, - password: clients.authorizedClient.id, + secret: clients.authorizedClient.id, requestedScopes: [`read:${target}`] }); } catch(e) { @@ -282,7 +282,7 @@ describe('http API', () => { result = await helpers.requestOAuth2AccessToken({ url, clientId: clients.authorizedClient.id, - password: clients.authorizedClient.id, + secret: clients.authorizedClient.id, requestedScopes: [`read:${target}`, `write:${target}`] }); } catch(e) { @@ -299,7 +299,7 @@ describe('http API', () => { result = await helpers.requestOAuth2AccessToken({ url, clientId: clients.authorizedClient.id, - password: clients.authorizedClient.id, + secret: clients.authorizedClient.id, requestedScopes: [`read:/`] }); } catch(e) { @@ -317,7 +317,7 @@ describe('http API', () => { result = await helpers.requestOAuth2AccessToken({ url, clientId: clients.unauthorizedClient.id, - password: clients.unauthorizedClient.id, + secret: clients.unauthorizedClient.id, requestedScopes: [`read:${target}`] }); } catch(e) { @@ -334,7 +334,7 @@ describe('http API', () => { } = await helpers.requestOAuth2AccessToken({ url, clientId: clients.authorizedClient.id, - password: clients.authorizedClient.id, + secret: clients.authorizedClient.id, requestedScopes: [`read:${target}`] }); let err; diff --git a/test/mocha/helpers.js b/test/mocha/helpers.js index 56450be..a7296d7 100644 --- a/test/mocha/helpers.js +++ b/test/mocha/helpers.js @@ -62,13 +62,13 @@ export async function doOAuth2Request({url, json, accessToken}) { } export async function requestOAuth2AccessToken({ - url, clientId, password, requestedScopes + url, clientId, secret, requestedScopes }) { const body = new URLSearchParams({ grant_type: 'client_credentials', scope: requestedScopes.join(' ') }); - const credentials = Buffer.from(`${clientId}:${password}`).toString('base64'); + const credentials = Buffer.from(`${clientId}:${secret}`).toString('base64'); const headers = { accept: 'application/json', authorization: `Basic ${credentials}` diff --git a/test/test.config.js b/test/test.config.js index c7e12c9..9fd0827 100644 --- a/test/test.config.js +++ b/test/test.config.js @@ -26,12 +26,12 @@ clients.authorizedClient = { 'read:/test-authorize-request', 'write:/test-authorize-request' ], - passwordHash: 'qpMmqCHdQ0FkyVCF1Sfuprt4jKZ4p4Id1LhSLxmdmu8' + secretHash: 'qpMmqCHdQ0FkyVCF1Sfuprt4jKZ4p4Id1LhSLxmdmu8' }; clients.unauthorizedClient = { id: '5165774d-fadc-484b-8a78-d2b049721b52', // no requestable scopes requestableScopes: [], // hash of `client_id` - passwordHash: 'JySRI3hb_DJ3rV4oUulOowEcLkRS4DCMdnfzJx57Z3g' + secretHash: 'JySRI3hb_DJ3rV4oUulOowEcLkRS4DCMdnfzJx57Z3g' };