diff --git a/.eslintrc.cjs b/.eslintrc.cjs new file mode 100644 index 0000000..a941733 --- /dev/null +++ b/.eslintrc.cjs @@ -0,0 +1,15 @@ +module.exports = { + root: true, + env: { + node: true + }, + extends: [ + 'digitalbazaar', + 'digitalbazaar/jsdoc', + 'digitalbazaar/module' + ], + ignorePatterns: ['node_modules/'], + rules: { + 'unicorn/prefer-node-protocol': 'error' + } +}; diff --git a/.eslintrc.js b/.eslintrc.js deleted file mode 100644 index 2297f74..0000000 --- a/.eslintrc.js +++ /dev/null @@ -1,7 +0,0 @@ -module.exports = { - root: true, - env: { - node: true - }, - extends: 'digitalbazaar' -}; diff --git a/.github/workflows/main.yml b/.github/workflows/main.yaml similarity index 74% rename from .github/workflows/main.yml rename to .github/workflows/main.yaml index 28e9f44..afe7888 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yaml @@ -8,11 +8,11 @@ jobs: timeout-minutes: 10 strategy: matrix: - node-version: [14.x] + node-version: [22.x] steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v4 - name: Use Node.js ${{ matrix.node-version }} - uses: actions/setup-node@v1 + uses: actions/setup-node@v4 with: node-version: ${{ matrix.node-version }} - run: npm install @@ -24,19 +24,20 @@ jobs: timeout-minutes: 10 services: mongodb: - image: mongo:4.2 + image: mongo:5 ports: - 27017:27017 strategy: matrix: - node-version: [12.x, 14.x] + node-version: [20.x, 22.x] steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v4 - name: Use Node.js ${{ matrix.node-version }} - uses: actions/setup-node@v1 + uses: actions/setup-node@v4 with: node-version: ${{ matrix.node-version }} - run: | + npm install cd test npm install - name: Run test with Node.js ${{ matrix.node-version }} @@ -49,19 +50,20 @@ jobs: timeout-minutes: 10 services: mongodb: - image: mongo:4.2 + image: mongo:5 ports: - 27017:27017 strategy: matrix: - node-version: [14.x] + node-version: [22.x] steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v4 - name: Use Node.js ${{ matrix.node-version }} - uses: actions/setup-node@v1 + uses: actions/setup-node@v4 with: node-version: ${{ matrix.node-version }} - run: | + npm install cd test npm install - name: Generate coverage report @@ -69,7 +71,8 @@ jobs: cd test npm run coverage-ci - name: Upload coverage to Codecov - uses: codecov/codecov-action@v1 + uses: codecov/codecov-action@v4 with: file: ./test/coverage/lcov.info fail_ci_if_error: true + token: ${{ secrets.CODECOV_TOKEN }} diff --git a/.npmrc b/.npmrc new file mode 100644 index 0000000..521a9f7 --- /dev/null +++ b/.npmrc @@ -0,0 +1 @@ +legacy-peer-deps=true diff --git a/CHANGELOG.md b/CHANGELOG.md index 2fc0dad..be32949 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,5 @@ -# bedrock-module-template-http ChangeLog +# bedrock-basic-authz-server ChangeLog -## 1.0.0 - TBD +## 1.0.0 - 2025-mm-dd - See git history for changes. diff --git a/LICENSE.md b/LICENSE.md index 91c3522..d379464 100644 --- a/LICENSE.md +++ b/LICENSE.md @@ -1,7 +1,7 @@ Bedrock Non-Commercial License v1.0 =================================== -Copyright (c) 2011-2021 Digital Bazaar, Inc. +Copyright (c) 2011-2025 Digital Bazaar, Inc. All rights reserved. Summary diff --git a/README.md b/README.md index 06e90df..9f0e534 100644 --- a/README.md +++ b/README.md @@ -1 +1 @@ -# bedrock-module-template-http \ No newline at end of file +# bedrock-basic-authz-server diff --git a/lib/config.js b/lib/config.js index f6120da..75e8511 100644 --- a/lib/config.js +++ b/lib/config.js @@ -1,13 +1,53 @@ /*! - * Copyright (c) 2021 Digital Bazaar, Inc. All rights reserved. + * Copyright (c) 2024-2025 Digital Bazaar, Inc. All rights reserved. */ -import bedrock from 'bedrock'; -const {config} = bedrock; +import {config} from '@bedrock/core'; +import {NAMESPACE} from './constants.js'; -const namespace = 'module-template-http'; -const cfg = config[namespace] = {}; +const cfg = config[NAMESPACE] = {}; -const basePath = '/foo'; -cfg.routes = { - basePath +cfg.authorizeZcapInvocationOptions = { + maxChainLength: 10, + // 300 second clock skew permitted by default + maxClockSkew: 300, + // 1 year max TTL by default + maxDelegationTtl: 1 * 60 * 60 * 24 * 365 * 1000 +}; + +cfg.authorization = { + oauth2: { + accessTokens: { + // TTL in seconds (default 24 hours = 86400 seconds) + ttl: 86400 + }, + routes: { + asMetadata: `/.well-known/oauth-authorization-server`, + token: `/openid/token`, + jwks: `/openid/jwks` + }, + // 300 second clock skew permitted by default + maxClockSkew: 300, + // note: using undefined `allowedAlgorithms` will use the defaults set + // by the `jose` library that are appropriate for the key / secret type; + // (i.e., only asymmetric crypto will be used here); the top-level/parent + // app should choose to either use `undefined` as the default or specify + // a more restrictive list + /*allowedAlgorithms: [ + // RSASSA-PKCS1-v1_ w/sha-XXX + 'RS256', + 'RS384', + 'RS512', + // RSASSA-PSS w/ SHA-XXX + 'PS256', + 'PS384', + 'PS512', + // ECDSA w/ SHA-XXX + 'ES256', + 'ES256K', + 'ES384', + 'ES512', + // ed25519 / ed448 + 'EdDSA' + ]*/ + } }; diff --git a/lib/constants.js b/lib/constants.js new file mode 100644 index 0000000..85b06a1 --- /dev/null +++ b/lib/constants.js @@ -0,0 +1,4 @@ +/*! + * Copyright (c) 2025 Digital Bazaar, Inc. All rights reserved. + */ +export const NAMESPACE = 'basic-authz-server'; diff --git a/lib/documentLoader.js b/lib/documentLoader.js new file mode 100644 index 0000000..53e14a2 --- /dev/null +++ b/lib/documentLoader.js @@ -0,0 +1,27 @@ +/*! + * Copyright (c) 2018-2025 Digital Bazaar, Inc. All rights reserved. + */ +import {documentLoader as brDocumentLoader} + from '@bedrock/jsonld-document-loader'; +import {didIo} from '@bedrock/did-io'; + +import '@bedrock/did-context'; +import '@bedrock/security-context'; +import '@bedrock/veres-one-context'; + +// load config defaults +import './config.js'; + +export async function documentLoader(url) { + if(url.startsWith('did:')) { + const document = await didIo.get({did: url}); + return { + contextUrl: null, + documentUrl: url, + document + }; + } + + // finally, try the bedrock document loader + return brDocumentLoader(url); +} diff --git a/lib/http.js b/lib/http.js deleted file mode 100644 index a57c31d..0000000 --- a/lib/http.js +++ /dev/null @@ -1,14 +0,0 @@ -/*! - * Copyright (c) 2021 Digital Bazaar, Inc. All rights reserved. - */ -import {asyncHandler} from 'bedrock-express'; -import bedrock from 'bedrock'; -const {config} = bedrock; - -bedrock.events.on('bedrock-express.configure.routes', app => { - const {routes} = config['module-template-http']; - app.post( - routes.basePath, - asyncHandler(async (/*req, res*/) => { - })); -}); diff --git a/lib/http/index.js b/lib/http/index.js new file mode 100644 index 0000000..dc43898 --- /dev/null +++ b/lib/http/index.js @@ -0,0 +1,7 @@ +/*! + * Copyright (c) 2024-2025 Digital Bazaar, Inc. All rights reserved. + */ +import * as middleware from './middleware.js'; +import {addOAuth2AuthzServer} from './oauth2.js'; + +export {middleware, addOAuth2AuthzServer}; diff --git a/lib/http/middleware.js b/lib/http/middleware.js new file mode 100644 index 0000000..4a0ef65 --- /dev/null +++ b/lib/http/middleware.js @@ -0,0 +1,229 @@ +/*! + * Copyright (c) 2021-2025 Digital Bazaar, Inc. All rights reserved. + */ +import * as bedrock from '@bedrock/core'; +import * as Ed25519Multikey from '@digitalbazaar/ed25519-multikey'; +import { + authorizeZcapInvocation as _authorizeZcapInvocation, + authorizeZcapRevocation as _authorizeZcapRevocation +} from '@digitalbazaar/ezcap-express'; +import assert from 'assert-plus'; +import {asyncHandler} from '@bedrock/express'; +import {checkAccessToken} from './oauth2.js'; +import {documentLoader} from '../documentLoader.js'; +import { + Ed25519Signature2020 +} from '@digitalbazaar/ed25519-signature-2020'; +import {getAppIdentity} from '@bedrock/app-identity'; +import {NAMESPACE} from '../constants.js'; + +const {util: {BedrockError}} = bedrock; + +// creates middleware for authorizing an HTTP request using the authz method +// detected in the request; presently supports both zcaps and oauth2 (but only +// may be used in a given request); each use the app identity as the root of +// security; zcap revocation is not supported by default, this can be +// overridden by passing `async isZcapRevoked({capabilities})` +export function authorizeRequest({ + expectedAction, isZcapRevoked = () => false +} = {}) { + // app identity is always the root controller for this middleware + const {id: rootController} = getAppIdentity(); + + const getExpectedValues = () => ({ + // allow expected action override + action: expectedAction, + host: bedrock.config.server.host, + rootInvocationTarget: bedrock.config.server.baseUri + }); + + const getRootController = () => rootController; + + const authzMiddleware = { + zcap: authorizeZcapInvocation({ + getExpectedValues, getRootController, isRevoked: isZcapRevoked + }), + oauth2: authorizeOAuth2AccessToken({getExpectedValues}) + }; + + return _useDetectedAuthzMethod({authzMiddleware}); +} + +// creates a middleware that checks OAuth2 JWT access token +export function authorizeOAuth2AccessToken({getExpectedValues}) { + return asyncHandler(async function authzOAuth2AccessToken(req, res, next) { + try { + await checkAccessToken({req, getExpectedValues}); + } catch(error) { + return onError({error}); + } + next(); + }); +} + +// calls ezcap-express's authorizeZcapInvocation w/constant params, exposing +// only those params that change in this module; zcap revocation not supported +// by default; requires override to use that feature +export function authorizeZcapInvocation({ + getExpectedValues, getRootController, isRevoked = () => false +} = {}) { + const {authorizeZcapInvocationOptions} = bedrock.config[NAMESPACE]; + return _authorizeZcapInvocation({ + documentLoader, getExpectedValues, getRootController, + getVerifier, + async inspectCapabilityChain({capabilityChain, capabilityChainMeta}) { + return _inspectCapabilityChain({ + capabilityChain, capabilityChainMeta, isRevoked + }); + }, + onError, + suiteFactory, + ...authorizeZcapInvocationOptions + }); +} + +// creates middleware for revocation of zcaps; +// `async isRevoked({capabilities})` must be provided for checking revocation +// status of the capabilities used in a given request +export function authorizeZcapRevocation({isRevoked = () => false} = {}) { + assert.func(isRevoked, 'isRevoked'); + const {id: rootController} = getAppIdentity(); + return _authorizeZcapRevocation({ + documentLoader, + expectedHost: bedrock.config.server.host, + getRootController() { + return rootController; + }, + getVerifier, + async inspectCapabilityChain({capabilityChain, capabilityChainMeta}) { + return _inspectCapabilityChain({ + capabilityChain, capabilityChainMeta, isRevoked + }); + }, + onError, + suiteFactory + }); +} + +// hook used to verify zcap invocation HTTP signatures +async function getVerifier({keyId, documentLoader}) { + const {document} = await documentLoader(keyId); + const key = await Ed25519Multikey.from(document); + const verificationMethod = await key.export( + {publicKey: true, includeContext: true}); + const verifier = key.verifier(); + return {verifier, verificationMethod}; +} + +function onError({error}) { + if(!(error instanceof BedrockError)) { + // always expose cause message and name; expose cause details as + // BedrockError if error is marked public + let details = {}; + if(error.details && error.details.public) { + details = error.details; + } + error = new BedrockError( + error.message, + error.name || 'NotAllowedError', { + ...details, + public: true, + }, error); + } + throw new BedrockError( + 'Authorization error.', 'NotAllowedError', { + httpStatusCode: 403, + public: true, + }, error); +} + +// hook used to create suites for verifying zcap delegation chains +async function suiteFactory() { + return new Ed25519Signature2020(); +} + +async function _inspectCapabilityChain({ + capabilityChain, capabilityChainMeta, isRevoked +}) { + // if capability chain has only root, there's nothing to check as root + // zcaps cannot be revoked + if(capabilityChain.length === 1) { + return {valid: true}; + } + + // collect capability IDs and delegators for all delegated capabilities in + // chain (skip root) so they can be checked for revocation + const capabilities = []; + for(const [i, capability] of capabilityChain.entries()) { + // skip root zcap, it cannot be revoked + if(i === 0) { + continue; + } + const [{purposeResult}] = capabilityChainMeta[i].verifyResult.results; + if(purposeResult && purposeResult.delegator) { + capabilities.push({ + capabilityId: capability.id, + delegator: purposeResult.delegator.id, + }); + } + } + + const revoked = await isRevoked({capabilities}); + if(revoked) { + return { + valid: false, + error: new Error( + 'One or more capabilities in the chain have been revoked.') + }; + } + + return {valid: true}; +} + +function _invokeMiddlewares({req, res, next, middlewares}) { + if(!Array.isArray(middlewares)) { + return middlewares(req, res, next); + } + if(middlewares.length === 1) { + return middlewares[0](req, res, next); + } + const middleware = middlewares.shift(); + const localNext = (...args) => { + if(args.length === 0) { + return _invokeMiddlewares({req, res, next, middlewares}); + } + next(...args); + }; + middleware(req, res, localNext); +} + +// create middleware that uses detected authz middleware +function _useDetectedAuthzMethod({authzMiddleware}) { + return function useDetectedAuthzMethod(req, res, next) { + const zcap = !!req.get('capability-invocation'); + const oauth2 = !!(req.get('authorization')?.startsWith('Bearer ')); + if(zcap && oauth2) { + return next(new BedrockError( + 'Only one authorization method is permitted per request.', + 'NotAllowedError', { + httpStatusCode: 403, + public: true, + })); + } + + // use middleware that matches authz method used in request + let mw; + if(zcap) { + mw = authzMiddleware.zcap; + } else if(oauth2) { + mw = authzMiddleware.oauth2; + } + // ensure an authz middleware always executes, including in cases where + // no authz method was used in request or where matching method is not + // enabled + mw = mw || authzMiddleware.zcap || authzMiddleware.oauth2; + + const middlewares = Array.isArray(mw) ? mw.slice() : mw; + _invokeMiddlewares({req, res, next, middlewares}); + }; +} diff --git a/lib/http/oauth2.js b/lib/http/oauth2.js new file mode 100644 index 0000000..98e2ff4 --- /dev/null +++ b/lib/http/oauth2.js @@ -0,0 +1,313 @@ +/*! + * Copyright (c) 2021-2025 Digital Bazaar, Inc. All rights reserved. + */ +import * as bedrock from '@bedrock/core'; +import * as Ed25519Multikey from '@digitalbazaar/ed25519-multikey'; +import { + checkTargetScopedAccessToken, + getBasicAuthorizationCredentials +} from '@bedrock/oauth2-verifier'; +import {createHash, timingSafeEqual} from 'node:crypto'; +import {importJWK, SignJWT} from 'jose'; +import {asyncHandler} from '@bedrock/express'; +import bodyParser from 'body-parser'; +import cors from 'cors'; +import {getAppIdentity} from '@bedrock/app-identity'; +import {logger} from '../logger.js'; +import {NAMESPACE} from '../constants.js'; +import { + oauthAccessTokenBody +} from '../../schemas/bedrock-basic-authz-server.js'; +import {createValidateMiddleware as validate} from '@bedrock/validation'; + +const {util: {BedrockError}} = bedrock; + +// initialize oauth issuer info; export for testing purposes only +export let OAUTH2_ISSUER; +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; + + OAUTH2_ISSUER = { + // has the issuer's DID + id, + // has the OAuth2 "issuer" metadata value + issuer: bedrock.config.server.baseUri, + configUrl: bedrock.config.server.baseUri + routes.asMetadata, + keyPair: null, + jwks: null + }; + + // ensure key pair can be imported and public key exported + try { + const importedKey = await Ed25519Multikey.from(capabilityInvocationKey); + const keyPair = await importedKey.export({ + secretKey: true, raw: true, canonicalize: true + }); + const [privateKeyJwk, publicKeyJwk] = await Promise.all([ + Ed25519Multikey.toJwk({ + keyPair, + secretKey: true + }), + Ed25519Multikey.toJwk({keyPair}) + ]); + privateKeyJwk.kid = capabilityInvocationKey.id; + privateKeyJwk.alg = 'Ed25519'; + publicKeyJwk.kid = capabilityInvocationKey.id; + publicKeyJwk.alg = 'Ed25519'; + const privateKey = await importJWK(privateKeyJwk); + OAUTH2_ISSUER.keyPair = {publicKeyJwk, privateKey}; + OAUTH2_ISSUER.jwks = {keys: [publicKeyJwk]}; + } catch(e) { + throw new BedrockError( + 'Could not import OAuth2 key pair.', { + name: 'DataError', + details: {httpStatusCode: 400, public: true}, + cause: e + }); + } +}); + +export function addOAuth2AuthzServer({app}) { + const cfg = bedrock.config[NAMESPACE]; + const {routes} = cfg.authorization.oauth2; + + // urlencoded body parser + const urlencodedSmall = bodyParser.urlencoded({ + // (extended=true for rich JSON-like representation) + extended: true + }); + + app.get( + routes.asMetadata, + cors(), + asyncHandler(async (req, res) => { + res.json({ + issuer: OAUTH2_ISSUER.issuer, + jwks_uri: routes.jwks, + token_endpoint: routes.token + }); + })); + + app.get( + routes.jwks, + cors(), + asyncHandler(async (req, res) => { + res.json(OAUTH2_ISSUER.jwks); + })); + + app.options(routes.token, cors()); + app.post( + routes.token, + cors(), + urlencodedSmall, + validate({bodySchema: oauthAccessTokenBody}), + asyncHandler(async (req, res) => { + let result; + try { + result = await _processAccessTokenRequest({req, res}); + } catch(error) { + return _sendOauth2Error({res, error}); + } + res.json(result); + })); +} + +export async function checkAccessToken({req, getExpectedValues} = {}) { + // pass optional system-wide supported algorithms as allow list ... note + // that `none` algorithm is always prohibited + const { + authorization: { + oauth2: {maxClockSkew, allowedAlgorithms} + } + } = bedrock.config[NAMESPACE]; + return checkTargetScopedAccessToken({ + req, issuerConfigUrl: OAUTH2_ISSUER.configUrl, getExpectedValues, + allowedAlgorithms, maxClockSkew + }); +} + +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); + + // ensure given password hash matches client record + if(!timingSafeEqual( + Buffer.from(client.passwordHash, 'utf8'), + Buffer.from(passwordHash, 'utf8'))) { + throw new BedrockError( + 'Invalid OAuth2 client password.', { + name: 'NotAllowedError', + details: { + httpStatusCode: 403, + public: true + } + }); + } +} + +function _camelToSnakeCase(s) { + return s.replace(/[A-Z]/g, (c, i) => (i === 0 ? '' : '_') + c.toLowerCase()); +} + +async function _checkBasicAuthorization({req}) { + try { + // parse credentials + // see: https://datatracker.ietf.org/doc/html/rfc7617#section-2 + const { + credentials: {userId: clientId, password} + } = getBasicAuthorizationCredentials({req}); + + // find matching client + const client = await _getOAuth2Client({clientId}); + + // assert password + await _assertOauth2ClientPassword({client, password}); + + return {client}; + } catch(cause) { + throw new BedrockError( + 'Basic authorization validation failed.', { + name: 'NotAllowedError', + details: { + httpStatusCode: 403, + public: true + }, + cause + }); + } +} + +async function _createAccessToken({client, request}) { + // get (and validate) requested scopes + const scope = _getRequestedScopes({client, request}); + + // 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; + + // create access token + const {id: iss, keyPair: {privateKey, publicKeyJwk: {alg}}} = OAUTH2_ISSUER; + const {basePath} = cfg.routes; + const audience = `${bedrock.config.server.baseUri}${basePath}`; + const {accessToken, ttl} = await _createOAuth2AccessToken({ + privateKey, alg, audience, scope, exp, iss + }); + return {accessToken, ttl}; +} + +async function _createOAuth2AccessToken({ + privateKey, alg, audience, scope, exp, iss, nbf, typ = 'at+jwt' +}) { + const builder = new SignJWT({scope}) + .setProtectedHeader({alg, typ}) + .setIssuer(iss) + .setAudience(audience); + let ttl; + if(exp !== undefined) { + builder.setExpirationTime(exp); + ttl = Math.max(0, exp - Math.floor(Date.now() / 1000)); + } else { + // default to 15 minute expiration time + builder.setExpirationTime('15m'); + ttl = Math.floor(Date.now() / 1000) + 15 * 60; + } + if(nbf !== undefined) { + builder.setNotBefore(nbf); + } + const accessToken = await builder.sign(privateKey); + return {accessToken, ttl}; +} + +function _getOAuth2Client({clientId}) { + // FIXME: get from config + return { + id: clientId, + scopes: ['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 + } + }); +} + +function _getRequestedScopes({client, request}) { + const scopes = request.scope.split(' '); + for(const scope of scopes) { + if(!client.scopes.includes(scope)) { + throw new BedrockError( + `Unauthorized scope "${scope}" requested.`, { + name: 'NotAllowedError', + details: { + httpStatusCode: 403, + public: true + } + }); + } + } + return scopes; +} + +async function _processAccessTokenRequest({req}) { + // only "client_credentials" grant type is supported + // see: https://datatracker.ietf.org/doc/html/rfc6749#section-4.4 + const { + grant_type: grantType, + scope + } = req.body; + + if(grantType !== 'client_credentials') { + // unsupported grant type + throw new BedrockError( + `Unsupported grant type "${grantType}".`, { + name: 'NotSupportedError', + details: {httpStatusCode: 400, public: true} + }); + } + + // create access token + const {client} = await _checkBasicAuthorization({req}); + const request = {scope}; + const {accessToken, ttl} = await _createAccessToken({client, request}); + return { + access_token: accessToken, + token_type: 'bearer', + expires_in: ttl + }; +} + +function _sendOauth2Error({res, error}) { + logger.error(error.message, {error}); + const status = error.details?.httpStatusCode ?? 500; + const oid4Error = { + error: _camelToSnakeCase(error.name ?? 'OperationError'), + error_description: error.message + }; + if(error?.details?.public) { + oid4Error.details = error.details; + // expose first level cause only + if(oid4Error.cause?.details?.public) { + oid4Error.cause = { + name: error.cause.name, + message: error.cause.message + }; + } + } + res.status(status).json(oid4Error); +} + +function _sha256(bufferOrString) { + return createHash('sha256').update(bufferOrString).digest(); +} diff --git a/lib/index.js b/lib/index.js index e731f70..cf8bd5f 100644 --- a/lib/index.js +++ b/lib/index.js @@ -1,8 +1,11 @@ /*! - * Copyright (c) 2021 Digital Bazaar, Inc. All rights reserved. + * Copyright (c) 2024-2025 Digital Bazaar, Inc. All rights reserved. */ -'use strict'; +import {addOAuth2AuthzServer, middleware} from './http/index.js'; +import {zcapClient} from './zcapClient.js'; -// translate `main.js` to CommonJS -require = require('esm')(module); -module.exports = require('./main.js'); +// load config defaults +import './config.js'; + +// export APIs +export {addOAuth2AuthzServer, middleware, zcapClient}; diff --git a/lib/logger.js b/lib/logger.js new file mode 100644 index 0000000..788f197 --- /dev/null +++ b/lib/logger.js @@ -0,0 +1,6 @@ +/*! + * Copyright (c) 2024-2025 Digital Bazaar, Inc. All rights reserved. + */ +import {loggers} from '@bedrock/core'; + +export const logger = loggers.get('app').child('bedrock-basic-authz-server'); diff --git a/lib/main.js b/lib/main.js deleted file mode 100644 index 7e65f16..0000000 --- a/lib/main.js +++ /dev/null @@ -1,7 +0,0 @@ -/*! - * Copyright (c) 2021 Digital Bazaar, Inc. All rights reserved. - */ -import 'bedrock-express'; - -import './config.js'; -import './http.js'; diff --git a/lib/zcapClient.js b/lib/zcapClient.js new file mode 100644 index 0000000..a507537 --- /dev/null +++ b/lib/zcapClient.js @@ -0,0 +1,21 @@ +/*! + * Copyright (c) 2024-2025 Digital Bazaar, Inc. All rights reserved. + */ +import * as bedrock from '@bedrock/core'; +import {Ed25519Signature2020} from '@digitalbazaar/ed25519-signature-2020'; +import {getAppIdentity} from '@bedrock/app-identity'; +import {httpsAgent} from '@bedrock/https-agent'; +import {ZcapClient} from '@digitalbazaar/ezcap'; + +export let zcapClient; + +bedrock.events.on('bedrock.init', () => { + // create signer using the application's capability invocation key + const {keys: {capabilityInvocationKey}} = getAppIdentity(); + + zcapClient = new ZcapClient({ + agent: httpsAgent, + invocationSigner: capabilityInvocationKey.signer(), + SuiteClass: Ed25519Signature2020 + }); +}); diff --git a/package.json b/package.json index fb148e6..f6e883d 100644 --- a/package.json +++ b/package.json @@ -1,17 +1,24 @@ { - "name": "bedrock-module-template-http", + "name": "@bedrock/basic-authz-server", "version": "0.0.1-0", - "description": "Bedrock HTTP API", - "main": "./lib", + "type": "module", + "description": "Bedrock Basic Authz Server", + "main": "./lib/index.js", + "files": [ + "lib/**/*.js", + "schemas/**/*.js" + ], "scripts": { - "lint": "eslint ." + "lint": "eslint --ext .cjs,.js ." }, "repository": { "type": "git", - "url": "https://github.com/digitalbazaar/bedrock-module-template-http" + "url": "https://github.com/digitalbazaar/bedrock-basic-authz-server" }, "keywords": [ - "bedrock" + "bedrock", + "zcap", + "oauth2" ], "author": { "name": "Digital Bazaar, Inc.", @@ -19,24 +26,38 @@ "url": "https://digitalbazaar.com" }, "bugs": { - "url": "https://github.com/digitalbazaar/bedrock-module-template-http/issues" + "url": "https://github.com/digitalbazaar/bedrock-basic-authz-server/issues" }, - "homepage": "https://github.com/digitalbazaar/bedrock-module-template-http", + "homepage": "https://github.com/digitalbazaar/bedrock-basic-authz-server", "dependencies": { - "esm": "^3.2.25" + "@digitalbazaar/ed25519-multikey": "^1.3.1", + "@digitalbazaar/ed25519-signature-2020": "^5.4.0", + "@digitalbazaar/ezcap": "^4.1.0", + "@digitalbazaar/ezcap-express": "^7.1.0", + "assert-plus": "^1.0.0", + "body-parser": "^1.20.2", + "cors": "^2.8.5", + "jose": "^5.6.3" }, "peerDependencies": { - "bedrock": "^4.1.1", - "bedrock-express": "^3.2.0" + "@bedrock/app-identity": "4.0.0", + "@bedrock/core": "^6.1.3", + "@bedrock/did-io": "^10.3.1", + "@bedrock/express": "^8.3.1", + "@bedrock/https-agent": "^4.1.0", + "@bedrock/oauth2-verifier": "^2.1.0", + "@bedrock/validation": "^7.1.0" }, "directories": { "lib": "./lib" }, "devDependencies": { - "eslint": "^7.14.0", - "eslint-config-digitalbazaar": "^2.6.1" + "eslint": "^8.57.0", + "eslint-config-digitalbazaar": "^5.2.0", + "eslint-plugin-jsdoc": "^50.6.3", + "eslint-plugin-unicorn": "^56.0.1" }, "engines": { - "node": ">=12" + "node": ">=18" } } diff --git a/schemas/bedrock-basic-authz-server.js b/schemas/bedrock-basic-authz-server.js new file mode 100644 index 0000000..afce70f --- /dev/null +++ b/schemas/bedrock-basic-authz-server.js @@ -0,0 +1,19 @@ +/*! + * Copyright (c) 2024-2025 Digital Bazaar, Inc. All rights reserved. + */ +export const oauthAccessTokenBody = { + title: 'OAuth Access Token Request', + type: 'object', + additionalProperties: false, + required: ['grant_type'], + properties: { + // only "client_credentials" grant type supported + // https://datatracker.ietf.org/doc/html/rfc6749#section-4.4 + grant_type: { + const: 'client_credentials' + }, + scope: { + type: 'string' + } + } +}; diff --git a/test/mocha/.eslintrc b/test/mocha/.eslintrc deleted file mode 100644 index f9f5022..0000000 --- a/test/mocha/.eslintrc +++ /dev/null @@ -1,9 +0,0 @@ -{ - "env": { - "mocha": true - }, - "globals": { - "assertNoError": true, - "should": true - } -} diff --git a/test/mocha/.eslintrc.cjs b/test/mocha/.eslintrc.cjs new file mode 100644 index 0000000..d757148 --- /dev/null +++ b/test/mocha/.eslintrc.cjs @@ -0,0 +1,9 @@ +module.exports = { + env: { + mocha: true + }, + globals: { + assertNoError: true, + should: true + } +}; diff --git a/test/mocha/10-api.js b/test/mocha/10-api.js index 6157d46..e6fbbb8 100644 --- a/test/mocha/10-api.js +++ b/test/mocha/10-api.js @@ -1,7 +1,251 @@ -/* - * Copyright (c) 2021 Digital Bazaar, Inc. All rights reserved. +/*! + * Copyright (c) 2024-2025 Digital Bazaar, Inc. All rights reserved. */ +import * as bedrock from '@bedrock/core'; +import * as helpers from './helpers.js'; +import {CapabilityAgent} from '@digitalbazaar/webkms-client'; +import {zcapClient} from '@bedrock/basic-authz-server'; -describe('api', () => { - it('should work'); +describe('http API', () => { + describe('authz request middleware', () => { + let capability; + const target = '/test-authorize-request'; + let unauthorizedZcapClient; + let url; + before(async () => { + const secret = '53ad64ce-8e1d-11ec-bb12-10bf48838a41'; + const handle = 'test'; + const capabilityAgent = await CapabilityAgent.fromSecret({ + secret, handle + }); + unauthorizedZcapClient = helpers.createZcapClient({capabilityAgent}); + const rootInvocationTarget = bedrock.config.server.baseUri; + url = `${rootInvocationTarget}${target}`; + capability = `urn:zcap:root:${encodeURIComponent(rootInvocationTarget)}`; + }); + const fixtures = [{ + name: 'GET', + async authorizedZcap() { + const result = await zcapClient.read({url, capability}); + return result.data; + }, + async unauthorizedZcap() { + const result = await unauthorizedZcapClient.read({url, capability}); + return result.data; + }, + async oauth2({accessToken}) { + const result = await helpers.doOAuth2Request({url, accessToken}); + return result.data; + } + }, { + name: 'POST', + async authorizedZcap() { + const result = await zcapClient.write({ + url, json: {foo: 'bar'}, capability + }); + return result.data; + }, + async unauthorizedZcap() { + const result = await unauthorizedZcapClient.write({ + url, json: {foo: 'bar'}, capability + }); + return result.data; + }, + async oauth2({accessToken}) { + const result = await helpers.doOAuth2Request({ + url, json: {foo: 'bar'}, accessToken + }); + return result.data; + } + }]; + // FIXME: remove me + fixtures.length = 1; + for(const fixture of fixtures) { + describe(fixture.name, () => { + it.only('succeeds using an authorized zcap', async () => { + let err; + let result; + try { + result = await fixture.authorizedZcap(); + } catch(e) { + err = e; + } + assertNoError(err); + should.exist(result); + result.should.deep.equal({success: true}); + }); + it('fails using an unauthorized zcap', async () => { + let err; + let result; + try { + result = await fixture.unauthorizedZcap(); + } catch(e) { + err = e; + } + should.exist(err); + should.not.exist(result); + err.status.should.equal(403); + err.data.type.should.equal('NotAllowedError'); + }); + it('succeeds using authorized access token', async () => { + const accessToken = await helpers.getOAuth2AccessToken({ + action: 'read', target + }); + let err; + let result; + try { + result = await fixture.oauth2({accessToken}); + } catch(e) { + err = e; + } + assertNoError(err); + should.exist(result); + result.should.deep.equal({success: true}); + }); + it('fails using an expired access token', async () => { + const accessToken = await helpers.getOAuth2AccessToken({ + action: 'read', target, + // expired 10 minutes ago + exp: Math.floor(Date.now() / 1000 - 600) + }); + let err; + let result; + try { + result = await fixture.oauth2({accessToken}); + } catch(e) { + err = e; + } + should.exist(err); + should.not.exist(result); + err.status.should.equal(403); + err.data.type.should.equal('NotAllowedError'); + should.exist(err.data.cause); + should.exist(err.data.cause.details); + should.exist(err.data.cause.details.code); + err.data.cause.details.code.should.equal('ERR_JWT_EXPIRED'); + should.exist(err.data.cause.details.claim); + err.data.cause.details.claim.should.equal('exp'); + }); + it('fails using an access token w/future "nbf" claim', async () => { + const accessToken = await helpers.getOAuth2AccessToken({ + action: 'read', target, + // 10 minutes from now + nbf: Math.floor(Date.now() / 1000 + 600) + }); + let err; + let result; + try { + result = await fixture.oauth2({accessToken}); + } catch(e) { + err = e; + } + should.exist(err); + should.not.exist(result); + err.status.should.equal(403); + err.data.type.should.equal('NotAllowedError'); + should.exist(err.data.cause); + should.exist(err.data.cause.details); + should.exist(err.data.cause.details.code); + err.data.cause.details.code.should.equal( + 'ERR_JWT_CLAIM_VALIDATION_FAILED'); + should.exist(err.data.cause.details.claim); + err.data.cause.details.claim.should.equal('nbf'); + }); + it('fails using an access token w/bad "typ" claim', async () => { + const accessToken = await helpers.getOAuth2AccessToken({ + action: 'read', target, + typ: 'unexpected' + }); + let err; + let result; + try { + result = await fixture.oauth2({accessToken}); + } catch(e) { + err = e; + } + should.exist(err); + should.not.exist(result); + err.status.should.equal(403); + err.data.type.should.equal('NotAllowedError'); + should.exist(err.data.cause); + should.exist(err.data.cause.details); + should.exist(err.data.cause.details.code); + err.data.cause.details.code.should.equal( + 'ERR_JWT_CLAIM_VALIDATION_FAILED'); + should.exist(err.data.cause.details.claim); + err.data.cause.details.claim.should.equal('typ'); + }); + it('fails using an access token w/bad "iss" claim', async () => { + const accessToken = await helpers.getOAuth2AccessToken({ + action: 'read', target, + iss: 'urn:example:unexpected' + }); + let err; + let result; + try { + result = await fixture.oauth2({accessToken}); + } catch(e) { + err = e; + } + should.exist(err); + should.not.exist(result); + err.status.should.equal(403); + err.data.type.should.equal('NotAllowedError'); + should.exist(err.data.cause); + should.exist(err.data.cause.details); + should.exist(err.data.cause.details.code); + err.data.cause.details.code.should.equal( + 'ERR_JWT_CLAIM_VALIDATION_FAILED'); + should.exist(err.data.cause.details.claim); + err.data.cause.details.claim.should.equal('iss'); + }); + it('fails using an access token w/bad action', async () => { + const accessToken = await helpers.getOAuth2AccessToken({ + action: 'incorrect', target + }); + let err; + let result; + try { + result = await fixture.oauth2({accessToken}); + } catch(e) { + err = e; + } + should.exist(err); + should.not.exist(result); + err.status.should.equal(403); + err.data.type.should.equal('NotAllowedError'); + should.exist(err.data.cause); + should.exist(err.data.cause.details); + should.exist(err.data.cause.details.code); + err.data.cause.details.code.should.equal( + 'ERR_JWT_CLAIM_VALIDATION_FAILED'); + should.exist(err.data.cause.details.claim); + err.data.cause.details.claim.should.equal('scope'); + }); + it('fails using an access token w/bad target', async () => { + const accessToken = await helpers.getOAuth2AccessToken({ + action: 'read', target: '/foo' + }); + let err; + let result; + try { + result = await helpers.doOAuth2Request({url, accessToken}); + } catch(e) { + err = e; + } + should.exist(err); + should.not.exist(result); + err.status.should.equal(403); + err.data.type.should.equal('NotAllowedError'); + should.exist(err.data.cause); + should.exist(err.data.cause.details); + should.exist(err.data.cause.details.code); + err.data.cause.details.code.should.equal( + 'ERR_JWT_CLAIM_VALIDATION_FAILED'); + should.exist(err.data.cause.details.claim); + err.data.cause.details.claim.should.equal('scope'); + }); + }); + } + }); }); diff --git a/test/mocha/helpers.js b/test/mocha/helpers.js new file mode 100644 index 0000000..1a4ea6f --- /dev/null +++ b/test/mocha/helpers.js @@ -0,0 +1,68 @@ +/* + * Copyright (c) 2019-2024 Digital Bazaar, Inc. All rights reserved. + */ +import * as bedrock from '@bedrock/core'; +import {Ed25519Signature2020} from '@digitalbazaar/ed25519-signature-2020'; +import {httpClient} from '@digitalbazaar/http-client'; +import {httpsAgent} from '@bedrock/https-agent'; +import {OAUTH2_ISSUER} from '@bedrock/basic-authz-server/lib/http/oauth2.js'; +import {SignJWT} from 'jose'; +import {ZcapClient} from '@digitalbazaar/ezcap'; + +export function createZcapClient({ + capabilityAgent, delegationSigner, invocationSigner +}) { + const signer = capabilityAgent && capabilityAgent.getSigner(); + return new ZcapClient({ + agent: httpsAgent, + invocationSigner: invocationSigner || signer, + delegationSigner: delegationSigner || signer, + SuiteClass: Ed25519Signature2020 + }); +} + +export async function delegate({ + capability, controller, invocationTarget, expires, allowedActions, + delegator +}) { + const zcapClient = createZcapClient({capabilityAgent: delegator}); + expires = expires || (capability && capability.expires) || + new Date(Date.now() + 5000).toISOString().slice(0, -5) + 'Z'; + return zcapClient.delegate({ + capability, controller, expires, invocationTarget, allowedActions + }); +} + +export async function doOAuth2Request({url, json, accessToken}) { + const method = json === undefined ? 'get' : 'post'; + const {data} = await httpClient[method](url, { + agent: httpsAgent, + headers: { + authorization: `Bearer ${accessToken}` + }, + json + }); + return data; +} + +export async function getOAuth2AccessToken({ + action, target, exp, iss, nbf, typ = 'at+jwt' +}) { + const audience = `${bedrock.config.server.baseUri}`; + const scope = `${action}:${target}`; + const builder = new SignJWT({scope}) + .setProtectedHeader({alg: 'EdDSA', typ}) + .setIssuer(iss ?? OAUTH2_ISSUER.issuer) + .setAudience(audience); + if(exp !== undefined) { + builder.setExpirationTime(exp); + } else { + // default to 5 minute expiration time + builder.setExpirationTime('5m'); + } + if(nbf !== undefined) { + builder.setNotBefore(nbf); + } + const {privateKey} = OAUTH2_ISSUER.keyPair; + return builder.sign(privateKey); +} diff --git a/test/package.json b/test/package.json index 752b392..55641d4 100644 --- a/test/package.json +++ b/test/package.json @@ -1,31 +1,53 @@ { - "name": "bedrock-module-template-http-test", - "version": "0.0.1-0", + "name": "bedrock-basic-authz-server-test", + "version": "0.0.1", + "type": "module", "private": true, "scripts": { "test": "node --preserve-symlinks test.js test", - "coverage": "cross-env NODE_ENV=test nyc --reporter=lcov --reporter=text-summary npm test", - "coverage-ci": "cross-env NODE_ENV=test nyc --reporter=lcovonly npm test", - "coverage-report": "nyc report" + "debug": "node --preserve-symlinks test.js test --log-level debug", + "coverage": "cross-env NODE_ENV=test c8 npm test", + "coverage-ci": "cross-env NODE_ENV=test c8 --reporter=lcovonly --reporter=text-summary --repoter=text npm test", + "coverage-report": "c8 report" }, "dependencies": { - "bedrock": "^4.1.1", - "bedrock-express": "^3.2.0", - "bedrock-https-agent": "^2.0.0", - "bedrock-module-template-http": "file:..", - "bedrock-mongodb": "^8.2.0", - "bedrock-server": "^2.7.0", - "bedrock-test": "^5.3.2", + "@bedrock/app-identity": "^4.0.0", + "@bedrock/basic-authz-server": "file:..", + "@bedrock/core": "^6.1.3", + "@bedrock/data-integrity-context": "^4.0.3", + "@bedrock/did-context": "^6.0.0", + "@bedrock/did-io": "^10.4.0", + "@bedrock/express": "^8.3.1", + "@bedrock/https-agent": "^4.1.0", + "@bedrock/jsonld-document-loader": "^5.1.0", + "@bedrock/ledger-context": "^25.0.0", + "@bedrock/multikey-context": "^3.0.0", + "@bedrock/oauth2-verifier": "^2.2.0", + "@bedrock/security-context": "^9.0.0", + "@bedrock/server": "^5.1.0", + "@bedrock/test": "^8.2.0", + "@bedrock/validation": "^7.1.0", + "@bedrock/veres-one-context": "^16.0.0", + "@digitalbazaar/ed25519-signature-2020": "^5.4.0", + "@digitalbazaar/ezcap": "^4.1.0", + "@digitalbazaar/http-client": "^4.1.1", + "@digitalbazaar/webkms-client": "^14.1.1", + "c8": "^10.1.2", "cross-env": "^7.0.3", - "nyc": "^15.1.0" + "jose": "^5.6.3" }, - "nyc": { + "c8": { "excludeNodeModules": false, "include": [ - "node_modules/bedrock-module-template-http/**" + "node_modules/@bedrock/basic-authz-server/**" ], "exclude": [ - "node_modules/bedrock-module-template-http/node_modules/**" + "node_modules/@bedrock/basic-authz-server/node_modules/**" + ], + "reporter": [ + "lcov", + "text-summary", + "text" ] } } diff --git a/test/test.config.js b/test/test.config.js index 2bd6421..3b9b24d 100644 --- a/test/test.config.js +++ b/test/test.config.js @@ -1,17 +1,19 @@ -/* - * Copyright (c) 2021 Digital Bazaar, Inc. All rights reserved. +/*! + * Copyright (c) 2020-2025 Digital Bazaar, Inc. All rights reserved. */ -'use strict'; - -const {config} = require('bedrock'); -const path = require('path'); - -// MongoDB -config.mongodb.name = 'bedrock_module_template_http_test'; -config.mongodb.dropCollections.onInit = true; -config.mongodb.dropCollections.collections = []; +import {config} from '@bedrock/core'; +import {fileURLToPath} from 'node:url'; +import path from 'node:path'; +import '@bedrock/app-identity'; +import '@bedrock/did-io'; +import '@bedrock/https-agent'; +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +config.mocha.options.fullTrace = true; config.mocha.tests.push(path.join(__dirname, 'mocha')); // allow self-signed certs in test framework config['https-agent'].rejectUnauthorized = false; + +// disable veres one fetching +config['did-io'].methodOverrides.v1.disableFetch = true; diff --git a/test/test.js b/test/test.js index cad3ac1..77fed1a 100644 --- a/test/test.js +++ b/test/test.js @@ -1,12 +1,27 @@ -/* - * Copyright (c) 2021 Digital Bazaar, Inc. All rights reserved. +/*! + * Copyright (c) 2024-2025 Digital Bazaar, Inc. All rights reserved. */ -'use strict'; +import * as bedrock from '@bedrock/core'; +import {addOAuth2AuthzServer, middleware} from '@bedrock/basic-authz-server'; +import '@bedrock/https-agent'; +import '@bedrock/express'; -const bedrock = require('bedrock'); -require('bedrock-https-agent'); -require('bedrock-mongodb'); -require('bedrock-module-template-http'); +// add OAuth2 authz server routes +bedrock.events.on('bedrock-express.configure.routes', app => { + addOAuth2AuthzServer({app}); -require('bedrock-test'); + // add middleware test routes + app.post( + '/test-authorize-request', + middleware.authorizeRequest(), (req, res) => { + res.json({success: true}); + }); + app.get( + '/test-authorize-request', + middleware.authorizeRequest(), (req, res) => { + res.json({success: true}); + }); +}); + +import '@bedrock/test'; bedrock.start();