UNPKG

@bedrock/basic-authz-server

Version:
383 lines (348 loc) 11.4 kB
/*! * Copyright (c) 2021-2026 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 assert from 'assert-plus'; 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; // 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 {clients, routes} = cfg.authorization.oauth2; const {baseUri} = bedrock.config.server; OAUTH2_ISSUER = { // has the issuer's DID id, // has the OAuth2 "issuer" metadata value issuer: baseUri, configUrl: `${baseUri}${routes.asMetadata}`, keyPair: null, jwks: null, config: { issuer: baseUri, jwks_uri: `${baseUri}${routes.jwks}`, token_endpoint: `${baseUri}${routes.token}`, grant_types_supported: ['client_credentials'] } }; // 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 = 'EdDSA'; publicKeyJwk.kid = capabilityInvocationKey.id; publicKeyJwk.alg = 'EdDSA'; 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 }); } // build map of client_id => client from named clients for(const clientName in clients) { const client = _importOAuth2Client({client: clients[clientName]}); CLIENT_MAP.set(client.id, client); } }); export function addOAuth2AuthzServer({app, getOAuth2Client} = {}) { 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 }); if(!getOAuth2Client) { getOAuth2Client = getOAuth2ClientFromConfig; } else { // ensure client is validated when using a custom client loader const fn = getOAuth2Client; getOAuth2Client = async ({clientId} = {}) => _importOAuth2Client({ client: await fn({clientId}) }); } app.get( routes.asMetadata, cors(), asyncHandler(async (req, res) => { res.json(OAUTH2_ISSUER.config); })); 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, getOAuth2Client}); } 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 }); } export function getOAuth2ClientFromConfig({clientId}) { 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; } async function _assertOauth2ClientSecret({client, secret}) { // client must have `secretHash`, a base64url-encoded SHA-256 hash of secret assert.string(client.secretHash, 'client.secretHash'); // 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 secret hash matches client record if(!timingSafeEqual( Buffer.from(client.secretHash, 'base64url'), secretHash)) { throw new BedrockError( 'Invalid OAuth2 client secret.', { 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, getOAuth2Client}) { try { // parse credentials // see: https://datatracker.ietf.org/doc/html/rfc7617#section-2 const { credentials: {userId: clientId, password: secret} } = getBasicAuthorizationCredentials({req}); // find matching client const client = await getOAuth2Client({clientId}); // assert secret await _assertOauth2ClientSecret({client, secret}); return {client}; } catch(cause) { throw new BedrockError( 'Basic authorization validation failed.', { name: 'NotAllowedError', details: { httpStatusCode: 403, public: true }, cause }); } } // export for testing purposes only export async function _createAccessToken({client, request}) { // get (and validate) requested scopes 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.now() / 1000) + accessTokens.ttl; // create access token const { issuer: iss, keyPair: {privateKey, publicKeyJwk: {alg, kid}} } = OAUTH2_ISSUER; const {audience} = client; const {accessToken, ttl} = await _createOAuth2AccessToken({ privateKey, alg, kid, audience, scope, exp, iss }); return {accessToken, ttl}; } // export for testing purposes only export async function _createOAuth2AccessToken({ privateKey, alg, kid, audience, scope, exp, iss, nbf, typ = 'at+jwt' }) { const builder = new SignJWT({scope}) .setProtectedHeader({alg, kid, 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 _getRequestedScopes({client, request}) { const scopes = [...new Set(request.scope.split(' '))]; const allowedScopes = client.allowedScopes ?? client.requestableScopes; for(const scope of scopes) { if(!allowedScopes?.includes(scope)) { throw new BedrockError( `Unauthorized scope "${scope}" requested.`, { name: 'NotAllowedError', details: { httpStatusCode: 403, public: true } }); } } return scopes; } // export for testing purposes only export 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; client is not an object.'); } const {baseUri} = bedrock.config.server; const { id, allowedScopes, audience = baseUri, requestableScopes } = client; assert.string(id, 'client.id'); // handle deprecated `requestableScopes` if(requestableScopes) { if(allowedScopes) { throw new TypeError( 'Only one of "allowedScopes" or "requestableScopes" is permitted.'); } assert.arrayOfString(requestableScopes, 'client.requestableScopes'); } else { assert.arrayOfString(allowedScopes, 'client.allowedScopes'); } const secretHash = client.secretHash ?? client.passwordHash; if(!(typeof secretHash === 'string' && Buffer.from(secretHash, 'base64url').length === 32)) { throw new TypeError( 'Invalid oauth2 client; ' + '"secretHash" (or deprecated "passwordHash") must be a ' + 'base64url-encoded SHA-256 hash of the ' + `client's sufficiently large, random secret.`); } return { id, allowedScopes: allowedScopes ?? requestableScopes, audience, secretHash }; } async function _processAccessTokenRequest({req, getOAuth2Client}) { // 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, getOAuth2Client}); 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(); }