From 80ed67b3841f2e4e49e4ee3ef990fcaf5cb74b56 Mon Sep 17 00:00:00 2001 From: Ryan Tate Date: Mon, 29 Sep 2025 16:54:10 -0700 Subject: [PATCH 01/12] add dc api workflow Signed-off-by: Ryan Tate --- .eslintrc.cjs | 22 +- common/jwt.js | 57 ++- configs/combined.example.yaml | 21 +- configs/config.js | 430 +++++++++------- lib/auth.js | 138 +++-- lib/http.js | 70 +-- lib/oidc.js | 89 ++-- lib/resolveClient.js | 65 ++- lib/workflows/base.js | 139 +++-- lib/workflows/dc-api-workflow.js | 267 ++++++++++ package.json | 3 +- web/components/DCApiView.vue | 337 ++++++++++++ web/components/ExchangeLayout.vue | 824 +++++++++++++++++------------- web/components/OID4VPView.vue | 506 +++++++++--------- 14 files changed, 1952 insertions(+), 1016 deletions(-) create mode 100644 lib/workflows/dc-api-workflow.js create mode 100644 web/components/DCApiView.vue diff --git a/.eslintrc.cjs b/.eslintrc.cjs index db6e1dd..60f9826 100644 --- a/.eslintrc.cjs +++ b/.eslintrc.cjs @@ -8,24 +8,28 @@ module.exports = { root: true, env: { - node: true + node: true, }, extends: [ 'plugin:quasar/standard', 'digitalbazaar', 'digitalbazaar/module', - 'digitalbazaar/vue3' - ], - ignorePatterns: [ - 'node_modules/', - 'dist/' + 'digitalbazaar/vue3', ], + ignorePatterns: ['node_modules/', 'dist/'], rules: { 'linebreak-style': [ 'error', - (process.platform === 'win32' ? 'windows' : 'unix') + process.platform === 'win32' ? 'windows' : 'unix', ], 'unicorn/prefer-node-protocol': 'error', - 'vue/no-v-html': 'off' - } + 'vue/no-v-html': 'off', + quotes: [ + 'error', + 'single', + { + avoidEscape: true, + }, + ], + }, }; diff --git a/common/jwt.js b/common/jwt.js index af50d43..9c7fc34 100644 --- a/common/jwt.js +++ b/common/jwt.js @@ -16,8 +16,8 @@ import jp from 'jsonpath'; * @param {import("../configs/config.js").RelyingParty} rp */ export const jwtFromExchange = async (exchange, rp) => { - const signingKey = config.opencred.signingKeys?.find( - sk => sk.purpose.includes('id_token') + const signingKey = config.opencred.signingKeys?.find(sk => + sk.purpose.includes('id_token'), ); if(!signingKey) { throw new Error('No signing key found in config with purpose id_token'); @@ -45,15 +45,60 @@ export const jwtFromExchange = async (exchange, rp) => { const header = { alg: signingKey.type, typ: 'JWT', - kid: signingKey.id + kid: signingKey.id, }; + // Handle DC API workflow differently + if(rp.workflow?.type === 'dc-api' && exchange.variables?.dcApiResponse) { + try { + const now = Math.floor(Date.now() / 1000); + const dcApiResponse = exchange.variables.dcApiResponse; + // Default to 1 hour + const expirySeconds = rp.idTokenExpirySeconds || 3600; + + const subject = + dcApiResponse.response['org.iso.18013.5.1'].document_number; + + const verified = + dcApiResponse.response.issuer_authentication == 'Valid' && + dcApiResponse.response.device_authentication == 'Valid'; + + const errors = dcApiResponse.response.errors; + + const payload = { + iss: config.server.baseUri, + aud: rp.clientId, + sub: subject || exchange.id, + iat: now, + exp: now + expirySeconds, + verified, + verification_method: 'dc-api', + verified_credentials: dcApiResponse.response, + }; + + if(errors !== null) { + payload.errors = errors; + } + + const jwt = await JWT.sign({payload, header, signFn}); + return jwt.toString(); + } catch(error) { + console.error('Error in DC API JWT generation:', error); + throw error; + } + } + + if(!exchange.variables?.results) { + return null; + } + const stepResultKey = Object.keys(exchange.variables.results).find( - v => v == exchange.step + v => v == exchange.step, ); const stepResults = exchange.variables.results[stepResultKey]; const c = jp.query( - stepResults, '$.verifiablePresentation.verifiableCredential[0]' + stepResults, + '$.verifiablePresentation.verifiableCredential[0]', ); if(!c.length) { return null; @@ -65,7 +110,7 @@ export const jwtFromExchange = async (exchange, rp) => { aud: rp.clientId, sub: c[0].credentialSubject.id, iat: now, - exp: now + rp.idTokenExpirySeconds + exp: now + rp.idTokenExpirySeconds, }; for(const {name, path} of rp.claims ?? []) { diff --git a/configs/combined.example.yaml b/configs/combined.example.yaml index fbfc966..df89003 100644 --- a/configs/combined.example.yaml +++ b/configs/combined.example.yaml @@ -5,7 +5,7 @@ app: # server: - # baseUri: https://evil-cows-return.loca.lt + # baseUri: https://evil-cows-return.loca.lt opencred: caStore: - pem: | @@ -282,12 +282,12 @@ app: required: true - type: number id: height - name: Height (cm) + name: Height (cm) path: "$.credentialSubject.height" required: false - type: dropdown id: sex - name: Sex + name: Sex path: "$.credentialSubject.sex" required: false options: @@ -295,7 +295,7 @@ app: "Female": 2 - type: dropdown id: senior_citizen - name: Are you a senior citizen? + name: Are you a senior citizen? path: "$.credentialSubject.senior_citizen" required: true options: @@ -334,3 +334,16 @@ app: exchangeErrorSubtitle: "Error details:" exchangeResetTitle: Please try again. exchangeReset: Retry exchange + # DC API specific translations + dcApiTitle: "Verify with Digital Credential" + dcApiLoading: "Requesting your digital credential..." + dcApiLoadingHelp: "Please follow the prompts in your browser to select and share your credential." + dcApiError: "Unable to verify credential" + dcApiReady: "Click To Verify with DC API" + dcApiRetry: "Try Again" + dcApiContinue: "Continue" + dcApiSwitchMethod: "Use a different verification method" + dcApiInitializing: "Initializing Digital Credential verification..." + dcApiHavingTrouble: "Having trouble?" + dcApiTryAnotherWay: "Try another way" + dcApiCancel: "Cancel DC API Verification Request" diff --git a/configs/config.js b/configs/config.js index 08ffaf4..228ae53 100644 --- a/configs/config.js +++ b/configs/config.js @@ -5,67 +5,65 @@ * SPDX-License-Identifier: BSD-3-Clause */ -import * as bedrock from '@bedrock/core'; -import {fileURLToPath} from 'node:url'; -import {klona} from 'klona'; -import path from 'node:path'; -import 'dotenv/config'; -import '@bedrock/views'; - -import {applyRpDefaults} from './configUtils.js'; -import {combineTranslations} from './translation.js'; -import {logger} from '../lib/logger.js'; - -const {config} = bedrock; +import * as bedrock from "@bedrock/core"; +import { fileURLToPath } from "node:url"; +import { klona } from "klona"; +import path from "node:path"; +import "dotenv/config"; +import "@bedrock/views"; + +import { applyRpDefaults } from "./configUtils.js"; +import { combineTranslations } from "./translation.js"; +import { logger } from "../lib/logger.js"; + +const { config } = bedrock; config.opencred = {}; const __dirname = path.dirname(fileURLToPath(import.meta.url)); -const rootPath = path.join(__dirname, '..'); +const rootPath = path.join(__dirname, ".."); -bedrock.events.on('bedrock-cli.parsed', async () => { - await import(path.join(config.paths.config, 'paths.js')); - await import(path.join(config.paths.config, 'core.js')); +bedrock.events.on("bedrock-cli.parsed", async () => { + await import(path.join(config.paths.config, "paths.js")); + await import(path.join(config.paths.config, "core.js")); }); -bedrock.events.on('bedrock.configure', async () => { - await import(path.join(config.paths.config, 'express.js')); - await import(path.join(config.paths.config, 'server.js')); - await import(path.join(config.paths.config, 'database.js')); - await import(path.join(config.paths.config, 'https-agent.js')); - await import(path.join(config.paths.config, 'authorization.js')); +bedrock.events.on("bedrock.configure", async () => { + await import(path.join(config.paths.config, "express.js")); + await import(path.join(config.paths.config, "server.js")); + await import(path.join(config.paths.config, "database.js")); + await import(path.join(config.paths.config, "https-agent.js")); + await import(path.join(config.paths.config, "authorization.js")); }); config.views.bundle.packages.push({ - path: path.join(rootPath, 'web'), - manifest: path.join(rootPath, 'web', 'manifest.json') + path: path.join(rootPath, "web"), + manifest: path.join(rootPath, "web", "manifest.json"), }); -config['bedrock-webpack'].configs.push({ +config["bedrock-webpack"].configs.push({ module: { - rules: [{ - test: /\.pcss$/i, - include: path.resolve(__dirname, '..', 'web'), - use: [ - 'style-loader', - 'css-loader', - { - loader: 'postcss-loader', - options: { - postcssOptions: { - plugins: [ - 'postcss-preset-env', - 'tailwindcss', - 'autoprefixer', - ] - } - } - } - ] - }] - } + rules: [ + { + test: /\.pcss$/i, + include: path.resolve(__dirname, "..", "web"), + use: [ + "style-loader", + "css-loader", + { + loader: "postcss-loader", + options: { + postcssOptions: { + plugins: ["postcss-preset-env", "tailwindcss", "autoprefixer"], + }, + }, + }, + ], + }, + ], + }, }); -bedrock.events.on('bedrock.init', async () => { - const {opencred} = config; +bedrock.events.on("bedrock.init", async () => { + const { opencred } = config; /** * @typedef {Object} VcApiWorkflow * @property {'vc-api'} type - The type of the workflow. @@ -84,8 +82,8 @@ bedrock.events.on('bedrock.init', async () => { * @property {string} verifiablePresentationRequest - What to request * @property {string} constraintsOverride - Override presentation definition * constraints with value - * @property {Object.} steps - Steps to execute - */ + * @property {Object.} steps - Steps to execute + */ /** * @typedef {Object} NativeWorkflow @@ -159,7 +157,7 @@ bedrock.events.on('bedrock.init', async () => { * record in seconds. (Default/max 900) */ - const availableExchangeProtocols = ['openid4vp', 'chapi']; + const availableExchangeProtocols = ["openid4vp", "chapi", "dc-api"]; /** * A list of exchange protocols in use by OpenCred * exchangeProtocols: ['openid4vp', 'chapi'] @@ -169,28 +167,38 @@ bedrock.events.on('bedrock.init', async () => { exchangeProtocols: availableExchangeProtocols, recordExpiresDurationMs: 86400000, // 1 day in milliseconds exchangeTtlSeconds: 900, // 15 minutes in seconds - ...(opencred.options || {}) + ...(opencred.options || {}), }; // Clamp recordExpiresDurationMs between 1 min and 30 days - opencred.options.recordExpiresDurationMs = Math.floor(Math.max( - Math.min(opencred.options.recordExpiresDurationMs, 86400000 * 30), - 60000 - )); + opencred.options.recordExpiresDurationMs = Math.floor( + Math.max( + Math.min(opencred.options.recordExpiresDurationMs, 86400000 * 30), + 60000, + ), + ); // Clamp exchange TTL between 10 and 900/recordDuration seconds - opencred.options.exchangeTtlSeconds = Math.floor(Math.min( - Math.max(opencred.options.exchangeTtlSeconds, 10), - Math.min(900, opencred.options.recordExpiresDurationMs / 1000))); - - if(!opencred.options.exchangeProtocols - .every(el => availableExchangeProtocols.includes(el))) { - throw new Error(`Invalid exchange protocol configured: ` + - `Found: [${opencred.options.exchangeProtocols}], ` + - `Available: [${availableExchangeProtocols}]`); + opencred.options.exchangeTtlSeconds = Math.floor( + Math.min( + Math.max(opencred.options.exchangeTtlSeconds, 10), + Math.min(900, opencred.options.recordExpiresDurationMs / 1000), + ), + ); + + if ( + !opencred.options.exchangeProtocols.every((el) => + availableExchangeProtocols.includes(el), + ) + ) { + throw new Error( + `Invalid exchange protocol configured: ` + + `Found: [${opencred.options.exchangeProtocols}], ` + + `Available: [${availableExchangeProtocols}]`, + ); } - if(!opencred.relyingParties) { + if (!opencred.relyingParties) { opencred.relyingParties = []; } /** @@ -201,77 +209,78 @@ bedrock.events.on('bedrock.init', async () => { // Workflow types const WorkflowType = { - VcApi: 'vc-api', - Native: 'native', - MicrosoftEntraVerifiedId: 'microsoft-entra-verified-id' + VcApi: "vc-api", + Native: "native", + MicrosoftEntraVerifiedId: "microsoft-entra-verified-id", + DcApi: "dc-api", }; const WorkFlowTypes = Object.values(WorkflowType); - const validateRelyingParty = rp => { - if(!rp.clientId) { - throw new Error('clientId is required for each configured relyingParty.'); + const validateRelyingParty = (rp) => { + if (!rp.clientId) { + throw new Error("clientId is required for each configured relyingParty."); } - if(!rp.clientSecret) { + if (!rp.clientSecret) { throw new Error(`clientSecret is required in ${rp.clientId}.`); } // Use redirectUri for proxy of OIDC being enabled or not - if(rp.redirectUri) { + if (rp.redirectUri) { // if redirectUri doesn't match http or https throw an error - if(!rp.redirectUri.match(/^https?:\/\//)) { + if (!rp.redirectUri.match(/^https?:\/\//)) { throw new Error(`redirectUri must be a URI in client ${rp.clientId}.`); } - if(!rp.scopes || !Array.isArray(rp.scopes)) { + if (!rp.scopes || !Array.isArray(rp.scopes)) { throw new Error( - `An array of scopes must be defined in client ${rp.clientId}.` + `An array of scopes must be defined in client ${rp.clientId}.`, ); } - if(!rp.scopes.map(s => s.name).includes('openid')) { + if (!rp.scopes.map((s) => s.name).includes("openid")) { throw new Error(`scopes in client ${rp.clientId} must include openid.`); } - if(!rp.idTokenExpirySeconds) { + if (!rp.idTokenExpirySeconds) { rp.idTokenExpirySeconds = 3600; } } }; - const validateWorkflow = rp => { - if(!rp.workflow) { - throw new Error('workflow must be defined.'); + const validateWorkflow = (rp) => { + if (!rp.workflow) { + throw new Error("workflow must be defined."); } - if(rp.workflow.type === WorkflowType.VcApi) { - if(!rp.workflow.baseUrl?.startsWith('http')) { + if (rp.workflow.type === WorkflowType.VcApi) { + if (!rp.workflow.baseUrl?.startsWith("http")) { throw new Error( - 'workflow baseUrl must be defined. This tool uses a VC-API exchange' + - ` endpoint to communicate with wallets. (client: ${rp.clientId})` + "workflow baseUrl must be defined. This tool uses a VC-API exchange" + + ` endpoint to communicate with wallets. (client: ${rp.clientId})`, ); - } else if(typeof rp.workflow.capability !== 'string') { + } else if (typeof rp.workflow.capability !== "string") { throw new Error( - `workflow capability must be defined. (client: ${rp.clientId})` + `workflow capability must be defined. (client: ${rp.clientId})`, ); - } else if( + } else if ( !rp.workflow.clientSecret || - typeof rp.workflow.clientSecret !== 'string' || + typeof rp.workflow.clientSecret !== "string" || rp.workflow.clientSecret.length < 1 ) { throw new Error( - `workflow clientSecret must be defined. (client: ${rp.clientId})` + `workflow clientSecret must be defined. (client: ${rp.clientId})`, ); } - } else if(rp.workflow.type === WorkflowType.Native) { - if(!rp.workflow.steps || Object.keys(rp.workflow.steps).length === 0) { + } else if (rp.workflow.type === WorkflowType.Native) { + if (!rp.workflow.steps || Object.keys(rp.workflow.steps).length === 0) { throw new Error( - `workflow must have at least 1 step. (client: ${rp.clientId})` + `workflow must have at least 1 step. (client: ${rp.clientId})`, ); } - if(!rp.workflow.initialStep) { + if (!rp.workflow.initialStep) { throw new Error( - `workflow initialStep must be set. (client: ${rp.clientId})` + `workflow initialStep must be set. (client: ${rp.clientId})`, ); } - } else if(rp.workflow.type === WorkflowType.MicrosoftEntraVerifiedId) { + } else if (rp.workflow.type === WorkflowType.MicrosoftEntraVerifiedId) { const { apiBaseUrl, apiLoginBaseUrl, @@ -281,98 +290,107 @@ bedrock.events.on('bedrock.init', async () => { verifierDid, verifierName, steps, - initialStep + initialStep, } = rp.workflow; - if(!apiBaseUrl) { + if (!apiBaseUrl) { throw new Error( - `apiBaseUrl is missing for workflow in client ${rp.clientId}.`); + `apiBaseUrl is missing for workflow in client ${rp.clientId}.`, + ); } - if(!apiLoginBaseUrl) { + if (!apiLoginBaseUrl) { throw new Error( - `apiLoginBaseUrl is missing for workflow in client ${rp.clientId}.`); + `apiLoginBaseUrl is missing for workflow in client ${rp.clientId}.`, + ); } - if(!apiClientId) { + if (!apiClientId) { throw new Error( - `apiClientId is missing for workflow in client ${rp.clientId}.`); + `apiClientId is missing for workflow in client ${rp.clientId}.`, + ); } - if(!apiClientSecret) { + if (!apiClientSecret) { throw new Error( - `apiClientSecret is missing for workflow in client ${rp.clientId}.`); + `apiClientSecret is missing for workflow in client ${rp.clientId}.`, + ); } - if(!apiTenantId) { + if (!apiTenantId) { throw new Error( - `apiTenantId is missing for workflow in client ${rp.clientId}.`); + `apiTenantId is missing for workflow in client ${rp.clientId}.`, + ); } - if(!verifierDid) { + if (!verifierDid) { throw new Error( - `verifierDid is missing for workflow in client ${rp.clientId}.`); + `verifierDid is missing for workflow in client ${rp.clientId}.`, + ); } - if(!verifierName) { + if (!verifierName) { throw new Error( - `verifierName is missing for workflow in client ${rp.clientId}.`); + `verifierName is missing for workflow in client ${rp.clientId}.`, + ); } - if(!steps) { + if (!steps) { throw new Error( - `steps is missing for workflow in client ${rp.clientId}.`); + `steps is missing for workflow in client ${rp.clientId}.`, + ); } - if(!initialStep) { + if (!initialStep) { throw new Error( - `initialStep is missing for workflow in client ${rp.clientId}.`); + `initialStep is missing for workflow in client ${rp.clientId}.`, + ); } - const {acceptedCredentialType} = steps[initialStep]; - if(!acceptedCredentialType) { + const { acceptedCredentialType } = steps[initialStep]; + if (!acceptedCredentialType) { throw new Error( - `acceptedCredentialType is missing for workflow in ${rp.clientId}.`); + `acceptedCredentialType is missing for workflow in ${rp.clientId}.`, + ); } + } else if (rp.workflow.type === WorkflowType.DcApi) { + console.log("Loading DC API Workflow"); } else { throw new Error( - 'workflow type must be one of the following values: ' + - `${WorkFlowTypes.map(v => `'${v}'`).join(', ')}.` + "workflow type must be one of the following values: " + + `${WorkFlowTypes.map((v) => `'${v}'`).join(", ")}.`, ); } }; // If relyingParties is not an array, throw an error - if(!Array.isArray(configRPs)) { - throw new Error('Configuration relyingParties must be an array.'); + if (!Array.isArray(configRPs)) { + throw new Error("Configuration relyingParties must be an array."); } - opencred.defaultLanguage = opencred.defaultLanguage || 'en'; + opencred.defaultLanguage = opencred.defaultLanguage || "en"; opencred.translations = combineTranslations(opencred.translations || {}); const defaultBrand = opencred.defaultBrand ?? { - cta: '#006847', - primary: '#008f5a', - header: '#004225' + cta: "#006847", + primary: "#008f5a", + header: "#004225", }; const validateDidWeb = () => { return { mainEnabled: opencred.didWeb?.mainEnabled, linkageEnabled: opencred.didWeb?.linkageEnabled, - mainDocument: JSON.parse(opencred.didWeb?.mainDocument ?? '{}'), - linkageDocument: JSON.parse(opencred.didWeb?.linkageDocument ?? '{}') + mainDocument: JSON.parse(opencred.didWeb?.mainDocument ?? "{}"), + linkageDocument: JSON.parse(opencred.didWeb?.linkageDocument ?? "{}"), }; }; opencred.didWeb = validateDidWeb(); const validateSigningKeys = () => { - if(!opencred.signingKeys) { + if (!opencred.signingKeys) { return []; } - opencred.signingKeys.forEach(sk => { - if(!sk.type) { - throw new Error('Each signingKey must have a type.'); + opencred.signingKeys.forEach((sk) => { + if (!sk.type) { + throw new Error("Each signingKey must have a type."); } - if(!Array.isArray(sk.purpose) || !sk.purpose?.length) { - throw new Error('Each signingKey must have at least one purpose.'); + if (!Array.isArray(sk.purpose) || !sk.purpose?.length) { + throw new Error("Each signingKey must have at least one purpose."); } - if( - sk.type == 'ES256' && - (!sk.privateKeyPem || !sk.publicKeyPem) - ) { + if (sk.type == "ES256" && (!sk.privateKeyPem || !sk.publicKeyPem)) { throw new Error( - 'Each ES256 signingKey must have a privateKeyPem and publicKeyPem.' + "Each ES256 signingKey must have a privateKeyPem and publicKeyPem.", ); } }); @@ -384,46 +402,48 @@ bedrock.events.on('bedrock.init', async () => { * A list of relying parties (connected apps or workflows) in use by OpenCred * @type {RelyingParty[]} */ - opencred.relyingParties = configRPs.map(rp => { + opencred.relyingParties = configRPs.map((rp) => { const app = applyRpDefaults(configRPs, rp); validateRelyingParty(app); validateWorkflow(app); const brand = { ...defaultBrand, - ...(app.brand ? app.brand : {}) + ...(app.brand ? app.brand : {}), }; return { ...app, - brand + brand, }; }); /** * A list of trusted issuers */ - const validateTrustedCredentialIssuers = scope => { - if(!scope.trustedCredentialIssuers) { + const validateTrustedCredentialIssuers = (scope) => { + if (!scope.trustedCredentialIssuers) { return; } - if(!Array.isArray(scope.trustedCredentialIssuers)) { - throw new Error('trustedCredentialIssuers must be an array'); + if (!Array.isArray(scope.trustedCredentialIssuers)) { + throw new Error("trustedCredentialIssuers must be an array"); } - for(const issuer of scope.trustedCredentialIssuers) { - if(typeof issuer !== 'string') { - throw new Error('Each issuer in trustedCredentialIssuers ' + - 'must be a string'); + for (const issuer of scope.trustedCredentialIssuers) { + if (typeof issuer !== "string") { + throw new Error( + "Each issuer in trustedCredentialIssuers " + "must be a string", + ); } } }; const applyDefaultTrustedCredentialIssuers = () => { opencred.trustedCredentialIssuers = opencred.trustedCredentialIssuers ?? []; validateTrustedCredentialIssuers(opencred); - for(const rp of opencred.relyingParties) { + for (const rp of opencred.relyingParties) { rp.trustedCredentialIssuers = rp.trustedCredentialIssuers ?? []; validateTrustedCredentialIssuers(rp); - rp.trustedCredentialIssuers = rp.trustedCredentialIssuers.length === 0 ? - opencred.trustedCredentialIssuers : - rp.trustedCredentialIssuers; + rp.trustedCredentialIssuers = + rp.trustedCredentialIssuers.length === 0 + ? opencred.trustedCredentialIssuers + : rp.trustedCredentialIssuers; } }; applyDefaultTrustedCredentialIssuers(); @@ -432,39 +452,41 @@ bedrock.events.on('bedrock.init', async () => { * Prepare a list of trusted root certificates */ const applyCaStoreDefaults = () => { - opencred.caStore = (opencred.caStore ?? []) - .map(cert => cert.pem); + opencred.caStore = (opencred.caStore ?? []).map((cert) => cert.pem); }; applyCaStoreDefaults(); /** * reCAPTCHA configuration */ - if(!opencred.reCaptcha) { + if (!opencred.reCaptcha) { opencred.reCaptcha = {}; } - if(!opencred.reCaptcha.pages) { + if (!opencred.reCaptcha.pages) { opencred.reCaptcha.pages = []; } - opencred.reCaptcha.enable = - opencred.reCaptcha.enable === true; + opencred.reCaptcha.enable = opencred.reCaptcha.enable === true; const availableReCaptchaVersions = [2, 3]; const validateReCaptcha = () => { - if(opencred.reCaptcha.enable) { - if(!opencred.reCaptcha.version || + if (opencred.reCaptcha.enable) { + if ( + !opencred.reCaptcha.version || !opencred.reCaptcha.siteKey || - !opencred.reCaptcha.secretKey) { + !opencred.reCaptcha.secretKey + ) { throw new Error( 'When the "reCaptcha.enable" config value is "true", ' + - 'the following config values must also be provided: ' + - '"reCaptcha.version", "reCaptcha.siteKey", and "reCaptcha.secretKey"' + "the following config values must also be provided: " + + '"reCaptcha.version", "reCaptcha.siteKey", and "reCaptcha.secretKey"', ); } - if(!availableReCaptchaVersions.includes(opencred.reCaptcha.version)) { - throw new Error('The config value of "reCaptcha.version" must be ' + - 'one of the following values: ' + - availableReCaptchaVersions.map(v => `"${v}"`).join(', ')); + if (!availableReCaptchaVersions.includes(opencred.reCaptcha.version)) { + throw new Error( + 'The config value of "reCaptcha.version" must be ' + + "one of the following values: " + + availableReCaptchaVersions.map((v) => `"${v}"`).join(", "), + ); } } }; @@ -473,14 +495,13 @@ bedrock.events.on('bedrock.init', async () => { /** * Auditing configuration */ - if(!opencred.audit) { + if (!opencred.audit) { opencred.audit = {}; } - if(!opencred.audit.fields) { + if (!opencred.audit.fields) { opencred.audit.fields = []; } - opencred.audit.enable = - opencred.audit.enable === true; + opencred.audit.enable = opencred.audit.enable === true; /** * A field to audit in a VP token @@ -494,47 +515,60 @@ bedrock.events.on('bedrock.init', async () => { * @property {string} options - Options for dropdown fields. */ - const requiredAuditFieldKeys = ['type', 'id', 'name', 'path', 'required']; - const auditFieldTypes = ['text', 'number', 'date', 'dropdown']; + const requiredAuditFieldKeys = ["type", "id", "name", "path", "required"]; + const auditFieldTypes = ["text", "number", "date", "dropdown"]; const validateAuditFields = () => { - if(opencred.audit.fields.length === 0) { + if (opencred.audit.fields.length === 0) { return; } - if(!Array.isArray(opencred.audit.fields)) { + if (!Array.isArray(opencred.audit.fields)) { throw new Error('The "audit.fields" config value must be an array.'); } - for(const field of opencred.audit.fields) { - if(!requiredAuditFieldKeys.every(f => Object.keys(field).includes(f))) { - throw new Error('Each object in "audit.fields" must have the ' + - 'following keys: ' + - requiredAuditFieldKeys.map(k => `"${k}"`).join(', ')); + for (const field of opencred.audit.fields) { + if ( + !requiredAuditFieldKeys.every((f) => Object.keys(field).includes(f)) + ) { + throw new Error( + 'Each object in "audit.fields" must have the ' + + "following keys: " + + requiredAuditFieldKeys.map((k) => `"${k}"`).join(", "), + ); } - if(!auditFieldTypes.includes(field.type)) { - throw new Error('Each object in "audit.fields" must have one of the ' + - 'following types: ' + - auditFieldTypes.map(t => `"${t}"`).join(', ')); + if (!auditFieldTypes.includes(field.type)) { + throw new Error( + 'Each object in "audit.fields" must have one of the ' + + "following types: " + + auditFieldTypes.map((t) => `"${t}"`).join(", "), + ); } } const auditFieldsHaveUniqueIds = klona(opencred.audit.fields) - .map(k => k.id) + .map((k) => k.id) .sort() - .reduce((unique, currentId, currentIndex, ids) => - unique && currentId !== ids[currentIndex - 1], true); - if(!auditFieldsHaveUniqueIds) { + .reduce( + (unique, currentId, currentIndex, ids) => + unique && currentId !== ids[currentIndex - 1], + true, + ); + if (!auditFieldsHaveUniqueIds) { throw new Error('Each object in "audit.fields" must have a unique "id".'); } const auditFieldsHaveUniquePaths = klona(opencred.audit.fields) - .map(k => k.id) + .map((k) => k.id) .sort() - .reduce((unique, currentPath, currentIndex, paths) => - unique && currentPath !== paths[currentIndex - 1], true); - if(!auditFieldsHaveUniquePaths) { - throw new Error('Each object in "audit.fields" must have ' + - 'a unique "path".'); + .reduce( + (unique, currentPath, currentIndex, paths) => + unique && currentPath !== paths[currentIndex - 1], + true, + ); + if (!auditFieldsHaveUniquePaths) { + throw new Error( + 'Each object in "audit.fields" must have ' + 'a unique "path".', + ); } }; validateAuditFields(); - logger.info('OpenCred Config Successfully Validated.'); + logger.info("OpenCred Config Successfully Validated."); }); diff --git a/lib/auth.js b/lib/auth.js index da93f9d..c34c05f 100644 --- a/lib/auth.js +++ b/lib/auth.js @@ -11,6 +11,7 @@ import {config} from '@bedrock/core'; const getAuthFunction = ({basic, bearer, body}) => { const ensureAuth = async (req, res, next) => { const authHeader = req.headers.authorization; + const parts = authHeader?.split(' ') ?? []; if(body && !req.rp) { const body_id = req.body?.client_id; @@ -18,7 +19,7 @@ const getAuthFunction = ({basic, bearer, body}) => { if(body_id && body_secret) { req.rp = config.opencred.relyingParties.find( - r => r.clientId == body_id && r.clientSecret == body_secret + r => r.clientId == body_id && r.clientSecret == body_secret, ); if(req.rp) { next(); @@ -27,21 +28,17 @@ const getAuthFunction = ({basic, bearer, body}) => { } } - if(basic && !req.rp && parts.length > 0) { + if(basic && !req.rp && parts.length > 0 && parts[0] == 'Basic') { const val = Buffer.from(parts[1], 'base64').toString('utf-8'); const authValueParts = val.split(':'); - if( - authValueParts.length !== 2 - ) { - res.status(401).send( - {message: 'Malformed Authorization header'} - ); + if(authValueParts.length !== 2) { + res.status(401).send({message: 'Malformed Authorization header'}); return; } const clientId = authValueParts[0]; const clientSecret = authValueParts[1]; req.rp = config.opencred.relyingParties.find( - r => r.clientId == clientId && r.clientSecret == clientSecret + r => r.clientId == clientId && r.clientSecret == clientSecret, ); if(req.rp) { next(); @@ -49,10 +46,45 @@ const getAuthFunction = ({basic, bearer, body}) => { } } - if(!req.rp) { - res.status(401).send( - {message: 'Client ID could not be resolved from request.'} + if(bearer && !req.rp && parts[0] == 'Bearer' && parts.length === 2) { + const {database} = await import('./database.js'); + const exchange = await database.collections.Exchanges.findOne( + { + ...(req.params.exchangeId ? {id: req.params.exchangeId} : {}), + accessToken: parts[1], + }, + {projection: {_id: 0}}, + ); + + if(!exchange) { + res.status(404).send({message: 'Exchange not found'}); + return; + } + + const expiry = new Date( + exchange.createdAt.getTime() + exchange.ttl * 1000, + ); + if(new Date() > expiry) { + res.status(401).send({message: 'Exchange has expired'}); + return; + } + + req.rp = config.opencred.relyingParties.find( + r => r.workflow.id === exchange.workflowId, ); + if(!req.rp) { + res.status(401).send({message: 'Invalid workflow ID in exchange'}); + return; + } + req.exchange = exchange; + next(); + return; + } + + if(!req.rp) { + res + .status(401) + .send({message: 'Client ID could not be resolved from request.'}); return; } const clientId = req.rp.clientId; @@ -64,9 +96,9 @@ const getAuthFunction = ({basic, bearer, body}) => { } if(!body && parts.length !== 2) { - res.status(401).send( - {message: 'Invalid Authorization format. Basic or Bearer required'} - ); + res.status(401).send({ + message: 'Invalid Authorization format. Basic or Bearer required', + }); return; } else if(basic && parts[0] == 'Basic') { const val = Buffer.from(parts[1], 'base64').toString('utf-8'); @@ -76,24 +108,16 @@ const getAuthFunction = ({basic, bearer, body}) => { authValueParts[0] !== clientId || authValueParts[1] !== clientSecret ) { - res.status(401).send( - {message: 'Malformed token or invalid clientId or clientSecret'} - ); + res.status(401).send({ + message: 'Malformed token or invalid clientId or clientSecret', + }); return; } } else if(bearer && parts[0] == 'Bearer') { - const exchange = await new BaseWorkflowService().getExchange({ - rp: req.rp, - ...(req.params.exchangeId ? {id: req.params?.exchangeId} : {}), - accessToken: parts[1], - allowExpired: true - }); - if(!exchange) { - res.status(404).send({message: 'Exchange not found'}); - return; - } - req.exchange = exchange; - if(exchange.workflowId !== req.rp.workflow.id) { + // Bearer auth is already handled above before the req.rp check + // This ensures the exchange and rp are already set + // Just validate that the workflowId matches + if(req.exchange && req.exchange.workflowId !== req.rp.workflow.id) { res.status(401).send({message: 'Invalid token'}); return; } @@ -102,15 +126,15 @@ const getAuthFunction = ({basic, bearer, body}) => { req.body.client_id !== clientId || req.body.client_secret !== clientSecret ) { - res.status(401).send( - {message: 'Malformed token or invalid clientId or clientSecret'} - ); + res.status(401).send({ + message: 'Malformed token or invalid clientId or clientSecret', + }); return; } } else { - res.status(401).send( - {message: 'Invalid Authorization header format. Basic auth required'} - ); + res.status(401).send({ + message: 'Invalid Authorization header format. Basic auth required', + }); return; } @@ -126,19 +150,41 @@ const getAuthFunction = ({basic, bearer, body}) => { * @param {Express} app - Express app instance */ export default function(app) { - app.post('/workflows/:workflowId/exchanges', getAuthFunction({ - basic: true, bearer: true, body: false - })); + app.post( + '/workflows/:workflowId/exchanges', + getAuthFunction({ + basic: true, + bearer: true, + body: false, + }), + ); app.get( - '/workflows/:workflowId/exchanges/:exchangeId', getAuthFunction({ - basic: true, bearer: true, body: false - }) + '/workflows/:workflowId/exchanges/:exchangeId', + getAuthFunction({ + basic: true, + bearer: true, + body: false, + }), ); app.post( '/workflows/:workflowId/exchanges/:exchangeId/reset', - getAuthFunction({basic: true, bearer: true, body: false}) + getAuthFunction({basic: true, bearer: true, body: false}), + ); + // DC API endpoints + app.get( + '/workflows/:workflowId/exchanges/:exchangeId/dc-api/request', + getAuthFunction({basic: true, bearer: true, body: false}), + ); + app.post( + '/workflows/:workflowId/exchanges/:exchangeId/dc-api/response', + getAuthFunction({basic: true, bearer: true, body: false}), + ); + app.post( + '/token', + getAuthFunction({ + basic: true, + bearer: false, + body: true, + }), ); - app.post('/token', getAuthFunction({ - basic: true, bearer: false, body: true - })); } diff --git a/lib/http.js b/lib/http.js index 51a8c96..306003c 100644 --- a/lib/http.js +++ b/lib/http.js @@ -15,25 +15,24 @@ import fs from 'node:fs'; import swaggerJsdoc from 'swagger-jsdoc'; import swaggerUi from 'swagger-ui-express'; +import {didConfigurationDocument, didWebDocument} from './didWeb.js'; import { - didConfigurationDocument, didWebDocument -} from './didWeb.js'; -import { - exchangeCodeForToken, jwksEndpoint, OidcValidationMiddleware, - openIdConfiguration + exchangeCodeForToken, + jwksEndpoint, + OidcValidationMiddleware, + openIdConfiguration, } from './oidc.js'; -import { - getExchangeStatus, initiateExchange -} from './api.js'; +import {getExchangeStatus, initiateExchange} from './api.js'; import {auditPresentation} from './audit.js'; import AuthenticationMiddleware from './auth.js'; import {combineTranslations} from '../configs/translation.js'; -import { - EntraVerifiedIdWorkflowService -} from './workflows/entra-verified-id-workflow.js'; +import {DCApiWorkflowService} from './workflows/dc-api-workflow.js'; +import {EntraVerifiedIdWorkflowService} from './workflows/entra-verified-id-workflow.js'; import {NativeWorkflowService} from './workflows/native-workflow.js'; import {newExchangeContext} from './workflows/common.js'; -import ResolveClientMiddleware from './resolveClient.js'; +import ResolveClientMiddleware, { + attachClientByBody, +} from './resolveClient.js'; import {VCApiWorkflowService} from './workflows/vc-api-workflow.js'; const routes = { @@ -48,7 +47,7 @@ const routes = { token: '/token', auditPresentation: '/audit-presentation', createExchange: '/workflows/:workflowId/exchanges', - exchangeStatus: '/workflows/:workflowId/exchanges/:exchangeId' + exchangeStatus: '/workflows/:workflowId/exchanges/:exchangeId', }; const __dirname = dirname(fileURLToPath(import.meta.url)); @@ -66,14 +65,14 @@ const options = { securitySchemes: { basic: { type: 'http', - scheme: 'basic' + scheme: 'basic', }, bearer: { type: 'http', - scheme: 'bearer' - } - } - } + scheme: 'bearer', + }, + }, + }, }, apis: [`${__dirname}/http.js`], // files containing annotations as above }; @@ -85,14 +84,17 @@ bedrock.events.on('bedrock-express.configure.bodyParser', app => { express.urlencoded({ limit: '200kb', extended: true, - }) + }), ); }); bedrock.events.on('bedrock-express.configure.routes', app => { app.use(cors()); app.use( - routes.apiDocs, swaggerUi.serve, swaggerUi.setup(openapiSpecification)); + routes.apiDocs, + swaggerUi.serve, + swaggerUi.setup(openapiSpecification), + ); app.get(routes.didWeb, didWebDocument); app.get(routes.didConfig, didConfigurationDocument); @@ -100,22 +102,22 @@ bedrock.events.on('bedrock-express.configure.routes', app => { app.get(routes.openIdConfig, openIdConfiguration); app.get(routes.config, (req, res) => { const rp = bedrock.config.opencred.relyingParties.find( - r => r.clientId == req.query.client_id + r => r.clientId == req.query.client_id, ); const { - defaultLanguage, translations: translationsDraft, options, defaultBrand, - customTranslateScript, audit, + defaultLanguage, + translations: translationsDraft, + options, + defaultBrand, + customTranslateScript, + audit, // Careful not to expose sensitive information here when pulling from // bedrock config! - reCaptcha: { - pages, - version, - siteKey - } + reCaptcha: {pages, version, siteKey}, } = bedrock.config.opencred; const translations = combineTranslations( rp?.translations ?? {}, - translationsDraft + translationsDraft, ); res.send({ defaultLanguage, @@ -125,8 +127,10 @@ bedrock.events.on('bedrock-express.configure.routes', app => { customTranslateScript, audit, reCaptcha: { - pages, version, siteKey - } + pages, + version, + siteKey, + }, }); }); @@ -146,6 +150,7 @@ bedrock.events.on('bedrock-express.configure.routes', app => { new NativeWorkflowService(app); new EntraVerifiedIdWorkflowService(app); new VCApiWorkflowService(app); + new DCApiWorkflowService(app); // VCAPIExchangeMiddleware(app); // MicrosoftEntraVerifiedIdExchangeMiddleware(app); @@ -287,7 +292,8 @@ bedrock.events.on('bedrock-express.configure.routes', app => { * "500": * description: Internal server error. */ - app.post(routes.token, exchangeCodeForToken); + // Token endpoint with body-based client authentication + app.post(routes.token, attachClientByBody, exchangeCodeForToken); /** * Verification Endpoints: GET /context/verification diff --git a/lib/oidc.js b/lib/oidc.js index b8a8b42..16a0da3 100644 --- a/lib/oidc.js +++ b/lib/oidc.js @@ -23,13 +23,13 @@ export const OidcValidationMiddleware = function(app) { if(!req.query.redirect_uri) { res.status(400).send({ error: 'invalid_grant', - error_description: 'redirect_uri is required' + error_description: 'redirect_uri is required', }); return; } else if(req.rp?.redirectUri != req.query.redirect_uri) { res.status(400).send({ error: 'invalid_grant', - error_description: 'Unknown redirect_uri' + error_description: 'Unknown redirect_uri', }); return; } @@ -38,13 +38,13 @@ export const OidcValidationMiddleware = function(app) { if(!req.query.scope) { res.status(400).send({ error: 'invalid_scope', - error_description: 'scope is required' + error_description: 'scope is required', }); return; } else if(req.query.scope !== 'openid') { res.status(400).send({ error: 'invalid_scope', - error_description: 'scope must be "openid"' + error_description: 'scope must be "openid"', }); return; } @@ -57,9 +57,9 @@ export const exchangeCodeForToken = async (req, res) => { // Client ID and Secret should be validated by ResolveClientMiddleware const rp = req.rp; if(!rp) { - res.status(500).send( - {message: 'Unexpected server error. No registered client attached.'} - ); + res.status(500).send({ + message: 'Unexpected server error. No registered client attached.', + }); return; } @@ -74,50 +74,56 @@ export const exchangeCodeForToken = async (req, res) => { res.status(400).send({message: 'grant_type is required'}); return; } else if(req.body.grant_type !== 'authorization_code') { - res.status(400).send( - { - error: 'unsupported_grant_type', - error_description: 'Invalid grant_type. Use authorization_code'} - ); + res.status(400).send({ + error: 'unsupported_grant_type', + error_description: 'Invalid grant_type. Use authorization_code', + }); return; } // Look up exchange by code and validate that it is for this RP. const exchange = await database.collections.Exchanges.findOne({ - 'oidc.code': req.body.code + 'oidc.code': req.body.code, }); if(!exchange) { res.status(400).send({ error: 'invalid_grant', - error_description: 'Invalid code' + error_description: 'Invalid code', }); return; } else if(exchange.workflowId !== rp.workflow.id) { res.status(400).send({ error: 'invalid_grant', - error_description: 'Invalid code or client_id' + error_description: 'Invalid code or client_id', }); return; } else if(exchange.state !== 'complete') { res.status(400).send({ error: 'invalid_grant', - error_description: `Invalid code: Exchange status ${exchange.state}` + error_description: `Invalid code: Exchange status ${exchange.state}`, }); return; } try { const jwt_string = await jwtFromExchange(exchange, rp); + if(!jwt_string) { + throw new Error('JWT generation returned null'); + } + const token = { access_token: 'NONE', token_type: 'Bearer', expires_in: 3600, - id_token: jwt_string + id_token: jwt_string, }; - await database.collections.Exchanges.updateOne({id: exchange.id}, { - $set: {oidc: {state: exchange.oidc?.state, code: null}} - }); + await database.collections.Exchanges.updateOne( + {id: exchange.id}, + { + $set: {oidc: {state: exchange.oidc?.state, code: null}}, + }, + ); res.send(token); return; @@ -125,40 +131,41 @@ export const exchangeCodeForToken = async (req, res) => { logger.error(error.message, {error}); res.status(500).send({ error: 'server_error', - error_description: 'Error creating JWT: ' + error.message + error_description: 'Error creating JWT: ' + error.message, }); return; } }; export const jwksEndpoint = async (req, res) => { - const jwks = config.opencred.signingKeys.filter( - key => key.purpose.includes('id_token') - ).map(key => { - const rehydratedKey = crypto.createPublicKey({ - key: key.publicKeyPem, - format: 'pem', - type: 'spki' + const jwks = config.opencred.signingKeys + .filter(key => key.purpose.includes('id_token')) + .map(key => { + const rehydratedKey = crypto.createPublicKey({ + key: key.publicKeyPem, + format: 'pem', + type: 'spki', + }); + const jwkFormat = rehydratedKey.export({format: 'jwk', type: 'public'}); + return { + kid: key.id, + ...jwkFormat, + }; }); - const jwkFormat = rehydratedKey.export({format: 'jwk', type: 'public'}); - return { - kid: key.id, - ...jwkFormat - }; - }); res.send({ - keys: jwks + keys: jwks, }); }; export const openIdConfiguration = async (req, res) => { - const {server: {baseUri}} = config; + const { + server: {baseUri}, + } = config; const id_token_signing_alg_values_supported = config.opencred.signingKeys - .filter( - key => key.purpose.includes('id_token') - ).map(k => k.type); + .filter(key => key.purpose.includes('id_token')) + .map(k => k.type); const info = { issuer: baseUri, authorization_endpoint: `${baseUri}/login`, @@ -171,7 +178,7 @@ export const openIdConfiguration = async (req, res) => { subject_types_supported: ['public'], token_endpoint_auth_methods_supported: [ 'client_secret_basic', - 'client_secret_post' + 'client_secret_post', ], id_token_signing_alg_values_supported, @@ -182,7 +189,7 @@ export const openIdConfiguration = async (req, res) => { claim_types_supported: ['normal'], claims_parameter_supported: false, service_documentation: 'https://github.com/digitalbazaar/opencred-platform', - ui_locales_supported: Object.keys(config.opencred.translations) + ui_locales_supported: Object.keys(config.opencred.translations), }; res.send(info); diff --git a/lib/resolveClient.js b/lib/resolveClient.js index f4328d1..fe96242 100644 --- a/lib/resolveClient.js +++ b/lib/resolveClient.js @@ -13,7 +13,7 @@ const attachClientByQuery = async (req, res, next) => { return; } const rp = config.opencred.relyingParties.find( - r => r.clientId == req.query.client_id + r => r.clientId == req.query.client_id, ); if(!rp) { res.status(400).send({message: 'Unknown client_id'}); @@ -23,9 +23,44 @@ const attachClientByQuery = async (req, res, next) => { next(); }; +// Middleware to attach client by body parameters (for token endpoint) +const attachClientByBody = async (req, res, next) => { + const {client_id, client_secret} = req.body; + + if(!client_id) { + // Skip - let the endpoint handle missing client_id + next(); + return; + } + + const rp = config.opencred.relyingParties.find( + r => r.clientId === client_id, + ); + + if(!rp) { + res.status(401).send({ + error: 'invalid_client', + error_description: 'Client not found', + }); + return; + } + + // Validate client secret if provided and configured + if(rp.clientSecret && client_secret !== rp.clientSecret) { + res.status(401).send({ + error: 'invalid_client', + error_description: 'Invalid client credentials', + }); + return; + } + + req.rp = rp; + next(); +}; + const attachClientByWorkflowId = async (req, res, next) => { const rp = config.opencred.relyingParties.find( - r => r.workflow?.id == req.params.workflowId + r => r.workflow?.id == req.params.workflowId, ); if(!rp) { res.status(404).send({message: 'Unknown workflow id'}); @@ -35,26 +70,40 @@ const attachClientByWorkflowId = async (req, res, next) => { next(); }; +// Export attachClientByBody so it can be used with the token endpoint +export {attachClientByBody}; + export default function(app) { app.get('/context/login', attachClientByQuery); app.get('/context/verification', attachClientByQuery); app.get( - '/workflows/:workflowId/exchanges/:exchangeId', attachClientByWorkflowId + '/workflows/:workflowId/exchanges/:exchangeId', + attachClientByWorkflowId, ); app.post('/workflows/:workflowId/exchanges', attachClientByWorkflowId); app.get( - '/workflows/:workflowId/exchanges/:exchangeId', attachClientByWorkflowId + '/workflows/:workflowId/exchanges/:exchangeId', + attachClientByWorkflowId, ); app.post( - '/workflows/:workflowId/exchanges/:exchangeId', attachClientByWorkflowId + '/workflows/:workflowId/exchanges/:exchangeId', + attachClientByWorkflowId, ); app.post( '/workflows/:workflowId/exchanges/:exchangeId/reset', - attachClientByWorkflowId + attachClientByWorkflowId, ); // eslint-disable-next-line max-len - app.get('/workflows/:workflowId/exchanges/:exchangeId/openid/client/authorization/request', attachClientByWorkflowId); + app.get( + '/workflows/:workflowId/exchanges/:exchangeId/openid/' + + 'client/authorization/request', + attachClientByWorkflowId, + ); // eslint-disable-next-line max-len - app.post('/workflows/:workflowId/exchanges/:exchangeId/openid/client/authorization/response', attachClientByWorkflowId); + app.post( + '/workflows/:workflowId/exchanges/:exchangeId/openid/' + + 'client/authorization/response', + attachClientByWorkflowId, + ); } diff --git a/lib/workflows/base.js b/lib/workflows/base.js index d975f30..208f879 100644 --- a/lib/workflows/base.js +++ b/lib/workflows/base.js @@ -1,32 +1,26 @@ -import base64url from 'base64url'; -import {config} from '@bedrock/core'; -import {createId} from '../../common/utils.js'; -import {database} from '../database.js'; -import {domainToDidWeb} from '../didWeb.js'; -import {logger} from '../logger.js'; +import base64url from "base64url"; +import { config } from "@bedrock/core"; +import { createId } from "../../common/utils.js"; +import { database } from "../database.js"; +import { domainToDidWeb } from "../didWeb.js"; +import { logger } from "../logger.js"; export class BaseWorkflowService { constructor(app) { - if(app) { - app.get( - '/context/login', - this.getOrCreateExchange.bind(this) - ); - app.get( - '/context/verification', - this.getOrCreateExchange.bind(this) - ); + if (app) { + app.get("/context/login", this.getOrCreateExchange.bind(this)); + app.get("/context/verification", this.getOrCreateExchange.bind(this)); app.post( - '/workflows/:workflowId/exchanges', - this.createExchange.bind(this) + "/workflows/:workflowId/exchanges", + this.createExchange.bind(this), ); app.get( - '/workflows/:workflowId/exchanges/:exchangeId', - this.getStatus.bind(this) + "/workflows/:workflowId/exchanges/:exchangeId", + this.getStatus.bind(this), ); app.post( - '/workflows/:workflowId/exchanges/:exchangeId/reset', - this.resetExchange.bind(this) + "/workflows/:workflowId/exchanges/:exchangeId/reset", + this.resetExchange.bind(this), ); } } @@ -34,46 +28,47 @@ export class BaseWorkflowService { // eslint-disable-next-line no-unused-vars async createWorkflowSpecificExchange(trustedVariables, untrustedVariables) { // eslint-disable-next-line no-unused-vars - const {rp, accessToken, oidc} = trustedVariables; + const { rp, accessToken, oidc } = trustedVariables; throw new Error( - 'Not implemented: createWorkflowSpecificExchange must be implemented ' + - 'in a workflow implementation.'); + "Not implemented: createWorkflowSpecificExchange must be implemented " + + "in a workflow implementation.", + ); } async resetExchange(req, res) { - const {exchange} = req; + const { exchange } = req; const updatedExchange = { ...exchange, - state: 'pending', + state: "pending", step: req.rp.workflow.initialStep, createdAt: new Date(), variables: { ...exchange.variables, results: {}, authorizationRequest: null, - } + }, }; await database.collections.Exchanges.replaceOne( - {id: exchange.id}, + { id: exchange.id }, updatedExchange, - {upsert: false} + { upsert: false }, ); res.send(this.formatExchange(updatedExchange)); } async initExchange(trustedVariables, untrustedVariables) { - const {rp, accessToken, oidc} = trustedVariables; + const { rp, accessToken, oidc } = trustedVariables; const duration = config.opencred.options.recordExpiresDurationMs; - const ttl = trustedVariables.ttl ?? - config.opencred.options.exchangeTtlSeconds; + const ttl = + trustedVariables.ttl ?? config.opencred.options.exchangeTtlSeconds; const gracePeriod = 60000; // 1 minute let variables = {}; - if(untrustedVariables && rp.workflow.untrustedVariableAllowList) { + if (untrustedVariables && rp.workflow.untrustedVariableAllowList) { variables = this.parseUntrustedVariables( rp.workflow.untrustedVariableAllowList, - untrustedVariables + untrustedVariables, ); } @@ -83,16 +78,17 @@ export class BaseWorkflowService { id: await createId(), challenge: await createId(), workflowId: rp.workflow.id, - state: 'pending', + state: "pending", sequence: 0, step: rp.workflow.initialStep, // Might be undefined for vc-api ttl, createdAt, recordExpiresAt: new Date( - createdAt.getTime() + Math.max(ttl * 1000 + gracePeriod, duration)), + createdAt.getTime() + Math.max(ttl * 1000 + gracePeriod, duration), + ), variables, oidc, - accessToken + accessToken, }; } @@ -100,107 +96,110 @@ export class BaseWorkflowService { const accessToken = await createId(); const oidc = { code: null, - state: req.query?.state ?? req.body?.oidcState ?? '' + state: req.query?.state ?? req.body?.oidcState ?? "", }; let untrustedVariables = {}; - if(req.query?.variables || req.body?.variables) { + if (req.query?.variables || req.body?.variables) { try { untrustedVariables = JSON.parse( - base64url.decode(req.query?.variables ?? req.body?.variables) + base64url.decode(req.query?.variables ?? req.body?.variables), ); - } catch(e) { + } catch (e) { res.status(400).send({ - message: 'Invalid variables supplied while creating exchange.' + message: "Invalid variables supplied while creating exchange.", }); return; } } try { const exchange = await this.createWorkflowSpecificExchange( - {rp: req.rp, accessToken, oidc}, - untrustedVariables + { rp: req.rp, accessToken, oidc }, + untrustedVariables, ); - if(exchange) { + if (exchange) { req.exchange = exchange; } next(); - } catch(e) { + } catch (e) { logger.error(e); - res.status(500).send({message: 'Internal Server Error'}); + res.status(500).send({ message: "Internal Server Error" }); } } async getStatus(req, res, next) { const rp = req.rp; - if(!rp?.workflow) { + if (!rp?.workflow) { next(); return; } - if(!req.exchange) { + if (!req.exchange) { req.exchange = await this.getExchange({ rp, id: req.params.exchangeId, - allowExpired: true + allowExpired: true, }); } next(); } async getOrCreateExchange(req, res, next) { - const {exchangeId, accessToken} = req.cookies; - if(!(exchangeId && accessToken)) { + const { exchangeId, accessToken } = req.cookies; + if (!(exchangeId && accessToken)) { return this.createExchange(req, res, next); } const exchange = await this.getExchange({ rp: req.rp, id: exchangeId, - accessToken + accessToken, }); - if(exchange) { + if (exchange) { req.exchange = this.formatExchange(exchange); } next(); } async getExchange( - {rp, id, accessToken, allowExpired} = {allowExpired: false} + { rp, id, accessToken, allowExpired } = { allowExpired: false }, ) { - const exchange = await database.collections.Exchanges.findOne({ - ...(id ? {id} : {}), - ...(accessToken ? {accessToken} : {}) - }, {projection: {_id: 0}}); - if(!exchange || !rp) { + const exchange = await database.collections.Exchanges.findOne( + { + ...(id ? { id } : {}), + ...(accessToken ? { accessToken } : {}), + }, + { projection: { _id: 0 } }, + ); + if (!exchange || !rp) { return null; } const expiry = new Date(exchange.createdAt.getTime() + exchange.ttl * 1000); - if(!allowExpired && new Date() > expiry) { + if (!allowExpired && new Date() > expiry) { return null; } // Necessary for hiding secret access token // from frontend for Entra relying parties // eslint-disable-next-line no-unused-vars - const {apiAccessToken, ...exchangeData} = exchange; + const { apiAccessToken, ...exchangeData } = exchange; return exchangeData; } formatExchange(exchange) { - if(!exchange) { + if (!exchange) { return null; } - const {id, accessToken, oidc, workflowId, ttl, createdAt} = exchange; + const { id, accessToken, oidc, workflowId, ttl, createdAt } = exchange; const domain = config.server.baseUri; const vcapi = `${domain}/workflows/${workflowId}/exchanges/${id}`; const authzReqUrl = `${vcapi}/openid/client/authorization/request`; const searchParams = new URLSearchParams({ client_id: domainToDidWeb(config.server.baseUri), - request_uri: authzReqUrl + request_uri: authzReqUrl, }); - const OID4VP = exchange.OID4VP ?? 'openid4vp://?' + searchParams.toString(); - return {id, vcapi, OID4VP, accessToken, oidc, ttl, createdAt, workflowId}; + const OID4VP = exchange.OID4VP ?? "openid4vp://?" + searchParams.toString(); + return { id, vcapi, OID4VP, accessToken, oidc, ttl, createdAt, workflowId }; } /** @@ -211,11 +210,11 @@ export class BaseWorkflowService { */ parseUntrustedVariables(untrustedVariableAllowList, untrustedVariables) { const variables = {}; - if(!untrustedVariables) { + if (!untrustedVariables) { return variables; } - for(const v of untrustedVariableAllowList) { - if(v in untrustedVariables) { + for (const v of untrustedVariableAllowList) { + if (v in untrustedVariables) { variables[v] = untrustedVariables[v]; } } diff --git a/lib/workflows/dc-api-workflow.js b/lib/workflows/dc-api-workflow.js new file mode 100644 index 0000000..1f2fe2e --- /dev/null +++ b/lib/workflows/dc-api-workflow.js @@ -0,0 +1,267 @@ +/*! + * Copyright 2023 - 2024 California Department of Motor Vehicles + * Copyright 2025 Spruce Systems, Inc. + * + * SPDX-License-Identifier: BSD-3-Clause + */ + +import { BaseWorkflowService } from "./base.js"; +import { config } from "@bedrock/core"; +import { logger } from "../logger.js"; +import { createId, logUtils } from "../../common/utils.js"; +import { database } from "../database.js"; +import { zcapClient } from "../../common/zcap.js"; +import { DcApi, JsOid4VpSessionStore } from "@spruceid/dc-api"; + +const WORKFLOW_TYPE = "dc-api"; + +const AUTHORIZATION_REQUEST_ENDPOINT = + "/workflows/:workflowId/exchanges/:exchangeId/dc-api/request"; + +const AUTHORIZATION_RESPONSE_ENDPOINT = + "/workflows/:workflowId/exchanges/:exchangeId/dc-api/response"; + +export class DCApiWorkflowService extends BaseWorkflowService { + /** @type {DcApi} */ + dcApi; + + dcApiSessionCache = new Map(); + + constructor(app) { + super(app); + + app.get( + AUTHORIZATION_REQUEST_ENDPOINT, + this.authorizationRequest.bind(this), + ); + app.post( + AUTHORIZATION_RESPONSE_ENDPOINT, + this.authorizationResponse.bind(this), + ); + + // Set the DC API Instance + this.initDcApi().then((dcApi) => { + this.dcApi = dcApi; + }); + } + + async initDcApi() { + const rp = config.opencred.relyingParties.find( + (rp) => rp.workflow.type === "dc-api", + ); + + const sk = config.opencred.signingKeys.find((k) => + k.purpose?.includes("authorization_request"), + ); + + const encoder = new TextEncoder(); + const dcApiConfig = { + key: sk.privateKeyPem, + baseUrl: rp?.workflow?.baseUrl, + submissionEndpoint: "", + referenceEndpoint: "", + certChainPem: encoder.encode(config.opencred.caStore[0]), + }; + + const submissionEndpoint = ""; + const referenceEndpoint = ""; + + // const dcApi = new DcApi(); + const dcApi = await DcApi.new( + sk.privateKeyPem, + rp?.workflow?.baseUrl, + submissionEndpoint, + referenceEndpoint, + encoder.encode(config.opencred.caStore[0]), + JsOid4VpSessionStore.createMemoryStore(), + // DC API Session + { + newSession: async (sessionId, session) => { + this.dcApiSessionCache.set(sessionId, session); + }, + getSession: async (id, clientSecret) => { + const session = this.dcApiSessionCache.get(id); + if (!session) { + return null; + } + // Return the session - the DC API will validate the client_secret hash + return session; + }, + getSessionUnauthenticated: async (id) => { + return this.dcApiSessionCache.get(id) || null; + }, + updateSession: async (sessionId, session) => { + this.dcApiSessionCache.set(sessionId, session); + }, + removeSession: async (sessionId) => { + this.dcApiSessionCache.delete(sessionId); + }, + }, + ); + + return dcApi; + } + + async createWorkflowSpecificExchange(trustedVariables, untrustedVariables) { + if (trustedVariables.rp?.workflow?.type !== WORKFLOW_TYPE) { + return; + } + + let dcApiSession = await this.dcApi.create_new_session(); + + const ex = await this.initExchange(trustedVariables, untrustedVariables); + if (!ex.workflowId) { + ex.workflowId = trustedVariables.rp.workflow.id; + } + + ex.variables = { + dcApiSession, + }; + + await database.collections.Exchanges.insertOne(ex); + + return this.formatExchange(ex); + } + + async authorizationRequest(req, res) { + const rp = req.rp; + const exchange = await this.getExchange({ rp, id: req.params.exchangeId }); + + logUtils.presentationStart(rp?.clientId, exchange?.id); + if (!exchange || exchange?.workflowId !== req.params.workflowId) { + const errorMessage = "Exchange not found"; + logUtils.presentationError(rp?.clientId, "unknown", errorMessage); + res.status(404).send({ message: errorMessage }); + return; + } + if (exchange.state !== "pending" && exchange.state !== "active") { + const errorMessage = `Exchange in state ${exchange.state}`; + logUtils.presentationError(rp?.clientId, exchange.id, errorMessage); + res.status(400).send(errorMessage); + return; + } + try { + const sessionId = exchange.variables.dcApiSession.id; + const sessionSecret = exchange.variables.dcApiSession.client_secret; + + if (!sessionSecret || !sessionId) { + res.status(500).send({ message: "Session data missing from exchange" }); + return; + } + + let requests = await this.dcApi.initiate_request( + sessionId, + sessionSecret, + JSON.parse(rp.workflow.dcApiRequest), + req.headers["user-agent"], + ); + + let updatedSession = this.dcApiSessionCache.get(sessionId); + exchange.variables.dcApiSession = { + ...exchange.variables.dcApiSession, + ...updatedSession, + }; + + await database.collections.Exchanges.updateOne( + { id: exchange.id }, + { + $set: { variables: exchange.variables, state: "active" }, + }, + ); + + res.send(requests); + } catch (error) { + logUtils.presentationError(rp?.clientId, exchange.id, error.message); + logger.error(error.message, { error }); + res.sendStatus(500); + } + return; + } + + async authorizationResponse(req, res) { + try { + const rp = req.rp; + + const exchange = await this.getExchange({ + rp, + id: req.params.exchangeId, + }); + + if (!exchange || exchange?.workflowId !== req.params.workflowId) { + const errorMessage = "Exchange not found"; + logUtils.presentationError(rp?.clientId, "unknown", errorMessage); + res.status(404).send({ message: errorMessage }); + return; + } + + if (exchange.state !== "active") { + const errorMessage = `Exchange in state ${exchange.state}`; + logUtils.presentationError(rp?.clientId, exchange.id, errorMessage); + res.status(400).send(errorMessage); + return; + } + + const results = await this.dcApi.submit_response( + exchange.variables.dcApiSession.id, + exchange.variables.dcApiSession.client_secret, + req.body, + ); + + // Generate OIDC authorization code for Keycloak/OAuth flows + const oidcCode = await createId(); + + await database.collections.Exchanges.updateOne( + { id: exchange.id }, + { + $set: { + state: "complete", + step: "verification", + "oidc.code": oidcCode, + variables: { + ...exchange.variables, + dcApiResponse: results, + results: { + verification: results, + }, + }, + }, + }, + ); + + // Send callback to relying party if configured + if (rp.workflow.callback) { + const { sendCallback } = await import("../callback.js"); + const callbackSuccess = await sendCallback( + rp.workflow, + { + ...exchange, + variables: { ...exchange.variables, dcApiResponse: results }, + }, + "verification", + ); + if (!callbackSuccess) { + logger.warn("Failed to send callback to relying party"); + } + } + + // Get the updated exchange to return to frontend with OIDC code + const updatedExchange = await database.collections.Exchanges.findOne({ + id: exchange.id, + }); + + // Return both results and updated exchange with OIDC code + res.send({ + ...results, + exchange: { + id: updatedExchange.id, + oidc: updatedExchange.oidc, + state: updatedExchange.state, + }, + }); + } catch (error) { + logUtils.presentationError(rp?.clientId, exchange.id, error.message); + logger.error(error.message, { error }); + res.sendStatus(500); + } + } +} diff --git a/package.json b/package.json index 0e88b7b..801def3 100644 --- a/package.json +++ b/package.json @@ -93,7 +93,8 @@ "vue-json-pretty": "^2.3.0", "vue-material-design-icons": "^5.3.0", "vue-router": "^4.3.0", - "x25519-key-agreement-2020-context": "^1.0.0" + "x25519-key-agreement-2020-context": "^1.0.0", + "@spruceid/dc-api": "file:../../SpruceId/dc-api/npm-package" }, "devDependencies": { "@bedrock/test": "^8.2.0", diff --git a/web/components/DCApiView.vue b/web/components/DCApiView.vue new file mode 100644 index 0000000..93deabe --- /dev/null +++ b/web/components/DCApiView.vue @@ -0,0 +1,337 @@ + + + + + + + diff --git a/web/components/ExchangeLayout.vue b/web/components/ExchangeLayout.vue index 0bb486e..db6843a 100644 --- a/web/components/ExchangeLayout.vue +++ b/web/components/ExchangeLayout.vue @@ -6,34 +6,41 @@ SPDX-License-Identifier: BSD-3-Clause --> diff --git a/web/components/OID4VPView.vue b/web/components/OID4VPView.vue index fd22b76..96e97ce 100644 --- a/web/components/OID4VPView.vue +++ b/web/components/OID4VPView.vue @@ -6,262 +6,302 @@ SPDX-License-Identifier: BSD-3-Clause --> From 2ac17e28d5acc2c7dba90c16174137b415b7df01 Mon Sep 17 00:00:00 2001 From: Ryan Tate Date: Mon, 29 Sep 2025 16:54:31 -0700 Subject: [PATCH 02/12] add key cloak docker compose configuration Signed-off-by: Ryan Tate --- docker-compose.yml | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) create mode 100644 docker-compose.yml diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..5d42b2f --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,27 @@ +version: "3.8" + +services: + mongodb: + image: mongo:6-jammy + restart: always + environment: + MONGO_INITDB_DATABASE: opencred_localhost + ports: + - "27017:27017" + volumes: + - data:/data/db + keycloak: + image: quay.io/keycloak/keycloak:26.3.3 + restart: always + environment: + KC_BOOTSTRAP_ADMIN_USERNAME: admin + KC_BOOTSTRAP_ADMIN_PASSWORD: admin + KC_LOG_LEVEL: info + KC_METRICS_ENABLED: "true" + KC_HEALTH_ENABLED: "true" + ports: + - "8081:8080" + command: start-dev + +volumes: + data: From 1292951a90b3352428a501f3a82b829e4646a36a Mon Sep 17 00:00:00 2001 From: Ryan Tate Date: Mon, 29 Sep 2025 16:56:42 -0700 Subject: [PATCH 03/12] uncomment exchange reset emission Signed-off-by: Ryan Tate --- web/components/DCApiView.vue | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/components/DCApiView.vue b/web/components/DCApiView.vue index 93deabe..4ccde2b 100644 --- a/web/components/DCApiView.vue +++ b/web/components/DCApiView.vue @@ -175,7 +175,7 @@ async function startDCApiFlow() { const retry = () => { console.log("Retrying DC API flow..."); // Reset the exchange to get a fresh session before retrying - // emit("resetExchange"); + emit("resetExchange"); // Small delay to allow the reset to complete setTimeout(() => { startDCApiFlow(); From e2924ba62869db7146d56b3ea121ef4f6421f47f Mon Sep 17 00:00:00 2001 From: Ryan Tate Date: Mon, 6 Oct 2025 10:38:16 -0700 Subject: [PATCH 04/12] wip: integrate session store directly with exchange Signed-off-by: Ryan Tate --- lib/workflows/dc-api-workflow.js | 51 +++++++++++++++++++++++++------- package.json | 2 +- web/components/DCApiView.vue | 6 ++-- 3 files changed, 44 insertions(+), 15 deletions(-) diff --git a/lib/workflows/dc-api-workflow.js b/lib/workflows/dc-api-workflow.js index 1f2fe2e..af71144 100644 --- a/lib/workflows/dc-api-workflow.js +++ b/lib/workflows/dc-api-workflow.js @@ -10,8 +10,7 @@ import { config } from "@bedrock/core"; import { logger } from "../logger.js"; import { createId, logUtils } from "../../common/utils.js"; import { database } from "../database.js"; -import { zcapClient } from "../../common/zcap.js"; -import { DcApi, JsOid4VpSessionStore } from "@spruceid/dc-api"; +import { DcApi, JsOid4VpSessionStore } from "@spruceid/opencred-dc-api"; const WORKFLOW_TYPE = "dc-api"; @@ -77,21 +76,47 @@ export class DCApiWorkflowService extends BaseWorkflowService { // DC API Session { newSession: async (sessionId, session) => { - this.dcApiSessionCache.set(sessionId, session); + // NOTE: This callback is handled within the `createWorkflowSpecificExchange` + // when `create_new_session` is called. + // The session is stored directly within the exchange. }, getSession: async (id, clientSecret) => { - const session = this.dcApiSessionCache.get(id); - if (!session) { - return null; - } - // Return the session - the DC API will validate the client_secret hash - return session; + const exchange = await database.collections.Exchanges.findOne( + { "variables.dcApiSession.id": id }, + { projection: { _id: 0, variables: 1 } }, + ); + return exchange?.variables?.dcApiSession || null; + + // const session = this.dcApiSessionCache.get(id); + // if (!session) { + // return null; + // } + // // Return the session - the DC API will validate the client_secret hash + // return session; }, getSessionUnauthenticated: async (id) => { - return this.dcApiSessionCache.get(id) || null; + // return this.dcApiSessionCache.get(id) || null; + const exchange = await database.collections.Exchanges.findOne( + { "variables.dcApiSession.id": id }, + { projection: { _id: 0, variables: 1 } }, + ); + return exchange?.variables?.dcApiSession || null; }, updateSession: async (sessionId, session) => { - this.dcApiSessionCache.set(sessionId, session); + // Use the exchangeId stored IN the session object for efficient lookup + if (session.exchangeId) { + await database.collections.Exchanges.updateOne( + { id: session.exchangeId }, + { $set: { "variables.dcApiSession": session } }, + ); + } else { + // Fallback: search by session.id + await database.collections.Exchanges.updateOne( + { "variables.dcApiSession.id": sessionId }, + { $set: { "variables.dcApiSession": session } }, + ); + } + // this.dcApiSessionCache.set(sessionId, session); }, removeSession: async (sessionId) => { this.dcApiSessionCache.delete(sessionId); @@ -114,6 +139,10 @@ export class DCApiWorkflowService extends BaseWorkflowService { ex.workflowId = trustedVariables.rp.workflow.id; } + // // Set the exchangeId on the dcApiSession + // dcApiSession.exchangeId = ex.id; + // dcApiSession.workflowId = ex.workflowId; + ex.variables = { dcApiSession, }; diff --git a/package.json b/package.json index 801def3..47cfeb8 100644 --- a/package.json +++ b/package.json @@ -94,7 +94,7 @@ "vue-material-design-icons": "^5.3.0", "vue-router": "^4.3.0", "x25519-key-agreement-2020-context": "^1.0.0", - "@spruceid/dc-api": "file:../../SpruceId/dc-api/npm-package" + "@spruceid/opencred-dc-api": "^0.1.2" }, "devDependencies": { "@bedrock/test": "^8.2.0", diff --git a/web/components/DCApiView.vue b/web/components/DCApiView.vue index 4ccde2b..e191984 100644 --- a/web/components/DCApiView.vue +++ b/web/components/DCApiView.vue @@ -1,6 +1,6 @@ @@ -100,7 +100,7 @@ async function startDCApiFlow() { console.log("Calling navigator.credentials.get()..."); const credentialResponse = await navigator.credentials.get({ signal: controller.signal, - mediation: "silent", + mediation: "required", digital: authRequest, }); @@ -175,7 +175,7 @@ async function startDCApiFlow() { const retry = () => { console.log("Retrying DC API flow..."); // Reset the exchange to get a fresh session before retrying - emit("resetExchange"); + // emit("resetExchange"); // Small delay to allow the reset to complete setTimeout(() => { startDCApiFlow(); From f451cb5664840176cf6770db36b0498230b073e7 Mon Sep 17 00:00:00 2001 From: Ryan Tate Date: Mon, 6 Oct 2025 18:38:07 -0700 Subject: [PATCH 05/12] fix eslint errors, use exchange as session store Signed-off-by: Ryan Tate --- .eslintrc.cjs | 24 ++- lib/auth.js | 1 - lib/http.js | 10 +- lib/workflows/base.js | 98 +++++------ lib/workflows/dc-api-workflow.js | 279 ++++++++++++++++++------------- package.json | 2 +- web/components/OID4VPView.vue | 34 ---- 7 files changed, 226 insertions(+), 222 deletions(-) diff --git a/.eslintrc.cjs b/.eslintrc.cjs index 60f9826..a43c3bb 100644 --- a/.eslintrc.cjs +++ b/.eslintrc.cjs @@ -8,28 +8,24 @@ module.exports = { root: true, env: { - node: true, + node: true }, extends: [ 'plugin:quasar/standard', 'digitalbazaar', 'digitalbazaar/module', - 'digitalbazaar/vue3', + 'digitalbazaar/vue3' + ], + ignorePatterns: [ + 'node_modules/', + 'dist/' ], - ignorePatterns: ['node_modules/', 'dist/'], rules: { 'linebreak-style': [ 'error', - process.platform === 'win32' ? 'windows' : 'unix', + (process.platform === 'win32' ? 'windows' : 'unix') ], 'unicorn/prefer-node-protocol': 'error', - 'vue/no-v-html': 'off', - quotes: [ - 'error', - 'single', - { - avoidEscape: true, - }, - ], - }, -}; + 'vue/no-v-html': 'off' + } +}; \ No newline at end of file diff --git a/lib/auth.js b/lib/auth.js index c34c05f..8f287f1 100644 --- a/lib/auth.js +++ b/lib/auth.js @@ -5,7 +5,6 @@ * SPDX-License-Identifier: BSD-3-Clause */ -import {BaseWorkflowService} from './workflows/base.js'; import {config} from '@bedrock/core'; const getAuthFunction = ({basic, bearer, body}) => { diff --git a/lib/http.js b/lib/http.js index 306003c..1769a39 100644 --- a/lib/http.js +++ b/lib/http.js @@ -23,16 +23,18 @@ import { openIdConfiguration, } from './oidc.js'; import {getExchangeStatus, initiateExchange} from './api.js'; +import {attachClientByBody} from './resolveClient.js'; import {auditPresentation} from './audit.js'; import AuthenticationMiddleware from './auth.js'; import {combineTranslations} from '../configs/translation.js'; import {DCApiWorkflowService} from './workflows/dc-api-workflow.js'; -import {EntraVerifiedIdWorkflowService} from './workflows/entra-verified-id-workflow.js'; +import { + EntraVerifiedIdWorkflowService +} from './workflows/entra-verified-id-workflow.js'; import {NativeWorkflowService} from './workflows/native-workflow.js'; import {newExchangeContext} from './workflows/common.js'; -import ResolveClientMiddleware, { - attachClientByBody, -} from './resolveClient.js'; + +import ResolveClientMiddleware from './resolveClient.js'; import {VCApiWorkflowService} from './workflows/vc-api-workflow.js'; const routes = { diff --git a/lib/workflows/base.js b/lib/workflows/base.js index 208f879..bbbdf7b 100644 --- a/lib/workflows/base.js +++ b/lib/workflows/base.js @@ -1,25 +1,25 @@ -import base64url from "base64url"; -import { config } from "@bedrock/core"; -import { createId } from "../../common/utils.js"; -import { database } from "../database.js"; -import { domainToDidWeb } from "../didWeb.js"; -import { logger } from "../logger.js"; +import base64url from 'base64url'; +import {config} from '@bedrock/core'; +import {createId} from '../../common/utils.js'; +import {database} from '../database.js'; +import {domainToDidWeb} from '../didWeb.js'; +import {logger} from '../logger.js'; export class BaseWorkflowService { constructor(app) { - if (app) { - app.get("/context/login", this.getOrCreateExchange.bind(this)); - app.get("/context/verification", this.getOrCreateExchange.bind(this)); + if(app) { + app.get('/context/login', this.getOrCreateExchange.bind(this)); + app.get('/context/verification', this.getOrCreateExchange.bind(this)); app.post( - "/workflows/:workflowId/exchanges", + '/workflows/:workflowId/exchanges', this.createExchange.bind(this), ); app.get( - "/workflows/:workflowId/exchanges/:exchangeId", + '/workflows/:workflowId/exchanges/:exchangeId', this.getStatus.bind(this), ); app.post( - "/workflows/:workflowId/exchanges/:exchangeId/reset", + '/workflows/:workflowId/exchanges/:exchangeId/reset', this.resetExchange.bind(this), ); } @@ -28,19 +28,19 @@ export class BaseWorkflowService { // eslint-disable-next-line no-unused-vars async createWorkflowSpecificExchange(trustedVariables, untrustedVariables) { // eslint-disable-next-line no-unused-vars - const { rp, accessToken, oidc } = trustedVariables; + const {rp, accessToken, oidc} = trustedVariables; throw new Error( - "Not implemented: createWorkflowSpecificExchange must be implemented " + - "in a workflow implementation.", + 'Not implemented: createWorkflowSpecificExchange must be implemented ' + + 'in a workflow implementation.', ); } async resetExchange(req, res) { - const { exchange } = req; + const {exchange} = req; const updatedExchange = { ...exchange, - state: "pending", + state: 'pending', step: req.rp.workflow.initialStep, createdAt: new Date(), variables: { @@ -50,22 +50,22 @@ export class BaseWorkflowService { }, }; await database.collections.Exchanges.replaceOne( - { id: exchange.id }, + {id: exchange.id}, updatedExchange, - { upsert: false }, + {upsert: false}, ); res.send(this.formatExchange(updatedExchange)); } async initExchange(trustedVariables, untrustedVariables) { - const { rp, accessToken, oidc } = trustedVariables; + const {rp, accessToken, oidc} = trustedVariables; const duration = config.opencred.options.recordExpiresDurationMs; const ttl = trustedVariables.ttl ?? config.opencred.options.exchangeTtlSeconds; const gracePeriod = 60000; // 1 minute let variables = {}; - if (untrustedVariables && rp.workflow.untrustedVariableAllowList) { + if(untrustedVariables && rp.workflow.untrustedVariableAllowList) { variables = this.parseUntrustedVariables( rp.workflow.untrustedVariableAllowList, untrustedVariables, @@ -78,7 +78,7 @@ export class BaseWorkflowService { id: await createId(), challenge: await createId(), workflowId: rp.workflow.id, - state: "pending", + state: 'pending', sequence: 0, step: rp.workflow.initialStep, // Might be undefined for vc-api ttl, @@ -96,44 +96,44 @@ export class BaseWorkflowService { const accessToken = await createId(); const oidc = { code: null, - state: req.query?.state ?? req.body?.oidcState ?? "", + state: req.query?.state ?? req.body?.oidcState ?? '', }; let untrustedVariables = {}; - if (req.query?.variables || req.body?.variables) { + if(req.query?.variables || req.body?.variables) { try { untrustedVariables = JSON.parse( base64url.decode(req.query?.variables ?? req.body?.variables), ); - } catch (e) { + } catch(e) { res.status(400).send({ - message: "Invalid variables supplied while creating exchange.", + message: 'Invalid variables supplied while creating exchange.', }); return; } } try { const exchange = await this.createWorkflowSpecificExchange( - { rp: req.rp, accessToken, oidc }, + {rp: req.rp, accessToken, oidc}, untrustedVariables, ); - if (exchange) { + if(exchange) { req.exchange = exchange; } next(); - } catch (e) { + } catch(e) { logger.error(e); - res.status(500).send({ message: "Internal Server Error" }); + res.status(500).send({message: 'Internal Server Error'}); } } async getStatus(req, res, next) { const rp = req.rp; - if (!rp?.workflow) { + if(!rp?.workflow) { next(); return; } - if (!req.exchange) { + if(!req.exchange) { req.exchange = await this.getExchange({ rp, id: req.params.exchangeId, @@ -144,8 +144,8 @@ export class BaseWorkflowService { } async getOrCreateExchange(req, res, next) { - const { exchangeId, accessToken } = req.cookies; - if (!(exchangeId && accessToken)) { + const {exchangeId, accessToken} = req.cookies; + if(!(exchangeId && accessToken)) { return this.createExchange(req, res, next); } const exchange = await this.getExchange({ @@ -153,44 +153,44 @@ export class BaseWorkflowService { id: exchangeId, accessToken, }); - if (exchange) { + if(exchange) { req.exchange = this.formatExchange(exchange); } next(); } async getExchange( - { rp, id, accessToken, allowExpired } = { allowExpired: false }, + {rp, id, accessToken, allowExpired} = {allowExpired: false}, ) { const exchange = await database.collections.Exchanges.findOne( { - ...(id ? { id } : {}), - ...(accessToken ? { accessToken } : {}), + ...(id ? {id} : {}), + ...(accessToken ? {accessToken} : {}), }, - { projection: { _id: 0 } }, + {projection: {_id: 0}}, ); - if (!exchange || !rp) { + if(!exchange || !rp) { return null; } const expiry = new Date(exchange.createdAt.getTime() + exchange.ttl * 1000); - if (!allowExpired && new Date() > expiry) { + if(!allowExpired && new Date() > expiry) { return null; } // Necessary for hiding secret access token // from frontend for Entra relying parties // eslint-disable-next-line no-unused-vars - const { apiAccessToken, ...exchangeData } = exchange; + const {apiAccessToken, ...exchangeData} = exchange; return exchangeData; } formatExchange(exchange) { - if (!exchange) { + if(!exchange) { return null; } - const { id, accessToken, oidc, workflowId, ttl, createdAt } = exchange; + const {id, accessToken, oidc, workflowId, ttl, createdAt} = exchange; const domain = config.server.baseUri; const vcapi = `${domain}/workflows/${workflowId}/exchanges/${id}`; const authzReqUrl = `${vcapi}/openid/client/authorization/request`; @@ -198,8 +198,8 @@ export class BaseWorkflowService { client_id: domainToDidWeb(config.server.baseUri), request_uri: authzReqUrl, }); - const OID4VP = exchange.OID4VP ?? "openid4vp://?" + searchParams.toString(); - return { id, vcapi, OID4VP, accessToken, oidc, ttl, createdAt, workflowId }; + const OID4VP = exchange.OID4VP ?? 'openid4vp://?' + searchParams.toString(); + return {id, vcapi, OID4VP, accessToken, oidc, ttl, createdAt, workflowId}; } /** @@ -210,11 +210,11 @@ export class BaseWorkflowService { */ parseUntrustedVariables(untrustedVariableAllowList, untrustedVariables) { const variables = {}; - if (!untrustedVariables) { + if(!untrustedVariables) { return variables; } - for (const v of untrustedVariableAllowList) { - if (v in untrustedVariables) { + for(const v of untrustedVariableAllowList) { + if(v in untrustedVariables) { variables[v] = untrustedVariables[v]; } } diff --git a/lib/workflows/dc-api-workflow.js b/lib/workflows/dc-api-workflow.js index af71144..f3bbeba 100644 --- a/lib/workflows/dc-api-workflow.js +++ b/lib/workflows/dc-api-workflow.js @@ -5,26 +5,24 @@ * SPDX-License-Identifier: BSD-3-Clause */ -import { BaseWorkflowService } from "./base.js"; -import { config } from "@bedrock/core"; -import { logger } from "../logger.js"; -import { createId, logUtils } from "../../common/utils.js"; -import { database } from "../database.js"; -import { DcApi, JsOid4VpSessionStore } from "@spruceid/opencred-dc-api"; +import {createId, logUtils} from '../../common/utils.js'; +import {DcApi, JsOid4VpSessionStore} from '@spruceid/opencred-dc-api'; +import {BaseWorkflowService} from './base.js'; +import {config} from '@bedrock/core'; +import {database} from '../database.js'; +import {logger} from '../logger.js'; -const WORKFLOW_TYPE = "dc-api"; +const WORKFLOW_TYPE = 'dc-api'; const AUTHORIZATION_REQUEST_ENDPOINT = - "/workflows/:workflowId/exchanges/:exchangeId/dc-api/request"; + '/workflows/:workflowId/exchanges/:exchangeId/dc-api/request'; const AUTHORIZATION_RESPONSE_ENDPOINT = - "/workflows/:workflowId/exchanges/:exchangeId/dc-api/response"; + '/workflows/:workflowId/exchanges/:exchangeId/dc-api/response'; export class DCApiWorkflowService extends BaseWorkflowService { - /** @type {DcApi} */ - dcApi; - - dcApiSessionCache = new Map(); + // /** @type {DcApi} */ + // dcApi; constructor(app) { super(app); @@ -39,31 +37,95 @@ export class DCApiWorkflowService extends BaseWorkflowService { ); // Set the DC API Instance - this.initDcApi().then((dcApi) => { + this.initDcApi().then(dcApi => { this.dcApi = dcApi; }); } async initDcApi() { const rp = config.opencred.relyingParties.find( - (rp) => rp.workflow.type === "dc-api", + rp => rp.workflow.type === 'dc-api', ); - const sk = config.opencred.signingKeys.find((k) => - k.purpose?.includes("authorization_request"), + const sk = config.opencred.signingKeys.find(k => + k.purpose?.includes('authorization_request'), ); const encoder = new TextEncoder(); - const dcApiConfig = { - key: sk.privateKeyPem, - baseUrl: rp?.workflow?.baseUrl, - submissionEndpoint: "", - referenceEndpoint: "", - certChainPem: encoder.encode(config.opencred.caStore[0]), - }; + const submissionEndpoint = ''; + const referenceEndpoint = ''; + + const oid4vpSessionStore = new JsOid4VpSessionStore({ + async initiate(session) { + console.log('Initiating OID4VP session'); + // NOTE: creating a new exchange for the oid4vp session + // within this callback function versus + // `createWorkflowSpecificExchange` method given constraints + // in how the session is managed internally. Rather than using + // the dc-api exchange created during the + // `createWorkflowSpecificExchange` execution, this oid4vp + // session store creates a secondary exchange, managed exclusively + // within these callbacks. The variables object provides the + // main details of the session under the `oid4vpSession`, while the + // other fields are provided to maintain compatbility + // with the existing `Exchange` database model. + try { + const duration = config.opencred.options.recordExpiresDurationMs; + const ttl = 1000 * config.opencred.options.exchangeTtlSeconds; + const gracePeriod = 1000 * 60; + const createdAt = new Date(); + + await database.collections.Exchanges.insertOne({ + id: await createId(), + challenge: await createId(), + workflowId: '', + state: 'pending', + sequence: 0, + step: 'initiated', + ttl, + createdAt, + recordExpiresAt: new Date( + createdAt.getTime() + Math.max(ttl + gracePeriod, duration), + ), + variables: { + oid4vpSession: session, + }, + oidc: '', + accessToken: '', + }); + } catch(e) { + console.error('Failed to create exchange:', e); + } + }, + async updateStatus(uuid, status) { + console.log(`Updating OID4VP session status to ${status}`); + // Update session status + await database.collections.Exchanges.updateOne( + { + 'variables.oid4vpSession.uuid': uuid, + }, + {$set: {'variables.oid4vpSession.status': status}}, + ); + }, + async getSession(uuid) { + console.log(`Fetching OID4VP session for UUID: ${uuid}`); + const exchange = await database.collections.Exchanges.findOne( + {'variables.oid4vpSession.uuid': uuid}, + {projection: {_id: 0, variables: 1}}, + ); + + if(!exchange || !exchange.variables.oid4vpSession) { + throw new Error(`OID4VP Session not found for UUID: ${uuid}`); + } - const submissionEndpoint = ""; - const referenceEndpoint = ""; + return exchange.variables.oid4vpSession; + }, + async removeSession(uuid) { + await database.collections.Exchanges.deleteOne({ + 'variables.oid4vpSession.uuid': uuid, + }); + }, + }); // const dcApi = new DcApi(); const dcApi = await DcApi.new( @@ -72,54 +134,45 @@ export class DCApiWorkflowService extends BaseWorkflowService { submissionEndpoint, referenceEndpoint, encoder.encode(config.opencred.caStore[0]), - JsOid4VpSessionStore.createMemoryStore(), + // JsOid4VpSessionStore.createMemoryStore(), + oid4vpSessionStore, // DC API Session { - newSession: async (sessionId, session) => { - // NOTE: This callback is handled within the `createWorkflowSpecificExchange` - // when `create_new_session` is called. - // The session is stored directly within the exchange. + newSession: async (/* sessionId, session */) => { + // NOTE: This callback is handled within the + // `createWorkflowSpecificExchange` when `create_new_session` + // is called. The session is stored directly within the exchange. }, - getSession: async (id, clientSecret) => { + getSession: async (id /*, clientSecret*/) => { + console.log(`Fetching DC API session for ID: ${id}`); const exchange = await database.collections.Exchanges.findOne( - { "variables.dcApiSession.id": id }, - { projection: { _id: 0, variables: 1 } }, + {'variables.dcApiSession.session_creation_response.id': id}, + {projection: {_id: 0, variables: 1}}, ); - return exchange?.variables?.dcApiSession || null; - - // const session = this.dcApiSessionCache.get(id); - // if (!session) { - // return null; - // } - // // Return the session - the DC API will validate the client_secret hash - // return session; + return exchange?.variables?.dcApiSession.session || null; }, - getSessionUnauthenticated: async (id) => { + getSessionUnauthenticated: async id => { + console.log(`Fetching unauthenticated DC API session for ID: ${id}`); // return this.dcApiSessionCache.get(id) || null; const exchange = await database.collections.Exchanges.findOne( - { "variables.dcApiSession.id": id }, - { projection: { _id: 0, variables: 1 } }, + {'variables.dcApiSession.session_creation_response.id': id}, + {projection: {_id: 0, variables: 1}}, ); - return exchange?.variables?.dcApiSession || null; + return exchange?.variables?.dcApiSession.session || null; }, updateSession: async (sessionId, session) => { - // Use the exchangeId stored IN the session object for efficient lookup - if (session.exchangeId) { - await database.collections.Exchanges.updateOne( - { id: session.exchangeId }, - { $set: { "variables.dcApiSession": session } }, - ); - } else { - // Fallback: search by session.id - await database.collections.Exchanges.updateOne( - { "variables.dcApiSession.id": sessionId }, - { $set: { "variables.dcApiSession": session } }, - ); - } - // this.dcApiSessionCache.set(sessionId, session); + console.log(`Updating DC API session for ID: ${sessionId}`); + await database.collections.Exchanges.updateOne( + { + 'variables.dcApiSession.session_creation_response.id': sessionId, + }, + {$set: {'variables.dcApiSession.session': session}}, + ); }, - removeSession: async (sessionId) => { - this.dcApiSessionCache.delete(sessionId); + removeSession: async sessionId => { + await database.collections.Exchanges.deleteOne({ + 'variables.dcApiSession.session_creation_response.id': sessionId, + }); }, }, ); @@ -128,21 +181,17 @@ export class DCApiWorkflowService extends BaseWorkflowService { } async createWorkflowSpecificExchange(trustedVariables, untrustedVariables) { - if (trustedVariables.rp?.workflow?.type !== WORKFLOW_TYPE) { + if(trustedVariables.rp?.workflow?.type !== WORKFLOW_TYPE) { return; } - let dcApiSession = await this.dcApi.create_new_session(); + const dcApiSession = await this.dcApi.create_new_session(); const ex = await this.initExchange(trustedVariables, untrustedVariables); - if (!ex.workflowId) { + if(!ex.workflowId) { ex.workflowId = trustedVariables.rp.workflow.id; } - // // Set the exchangeId on the dcApiSession - // dcApiSession.exchangeId = ex.id; - // dcApiSession.workflowId = ex.workflowId; - ex.variables = { dcApiSession, }; @@ -154,54 +203,50 @@ export class DCApiWorkflowService extends BaseWorkflowService { async authorizationRequest(req, res) { const rp = req.rp; - const exchange = await this.getExchange({ rp, id: req.params.exchangeId }); + const exchange = await this.getExchange({rp, id: req.params.exchangeId}); logUtils.presentationStart(rp?.clientId, exchange?.id); - if (!exchange || exchange?.workflowId !== req.params.workflowId) { - const errorMessage = "Exchange not found"; - logUtils.presentationError(rp?.clientId, "unknown", errorMessage); - res.status(404).send({ message: errorMessage }); + if(!exchange || exchange?.workflowId !== req.params.workflowId) { + const errorMessage = 'Exchange not found'; + logUtils.presentationError(rp?.clientId, 'unknown', errorMessage); + res.status(404).send({message: errorMessage}); return; } - if (exchange.state !== "pending" && exchange.state !== "active") { + if(exchange.state !== 'pending' && exchange.state !== 'active') { const errorMessage = `Exchange in state ${exchange.state}`; logUtils.presentationError(rp?.clientId, exchange.id, errorMessage); res.status(400).send(errorMessage); return; } try { - const sessionId = exchange.variables.dcApiSession.id; - const sessionSecret = exchange.variables.dcApiSession.client_secret; + const sessionId = + exchange.variables.dcApiSession.session_creation_response.id; + const sessionSecret = + exchange.variables.dcApiSession.session_creation_response.client_secret; - if (!sessionSecret || !sessionId) { - res.status(500).send({ message: "Session data missing from exchange" }); + if(!sessionSecret || !sessionId) { + res.status(500).send({message: 'Session data missing from exchange'}); return; } - let requests = await this.dcApi.initiate_request( + const requests = await this.dcApi.initiate_request( sessionId, sessionSecret, JSON.parse(rp.workflow.dcApiRequest), - req.headers["user-agent"], + req.headers['user-agent'], ); - let updatedSession = this.dcApiSessionCache.get(sessionId); - exchange.variables.dcApiSession = { - ...exchange.variables.dcApiSession, - ...updatedSession, - }; - await database.collections.Exchanges.updateOne( - { id: exchange.id }, + {id: exchange.id}, { - $set: { variables: exchange.variables, state: "active" }, + $set: {state: 'active'}, }, ); res.send(requests); - } catch (error) { + } catch(error) { logUtils.presentationError(rp?.clientId, exchange.id, error.message); - logger.error(error.message, { error }); + logger.error(error.message, {error}); res.sendStatus(500); } return; @@ -216,14 +261,14 @@ export class DCApiWorkflowService extends BaseWorkflowService { id: req.params.exchangeId, }); - if (!exchange || exchange?.workflowId !== req.params.workflowId) { - const errorMessage = "Exchange not found"; - logUtils.presentationError(rp?.clientId, "unknown", errorMessage); - res.status(404).send({ message: errorMessage }); + if(!exchange || exchange?.workflowId !== req.params.workflowId) { + const errorMessage = 'Exchange not found'; + logUtils.presentationError(rp?.clientId, 'unknown', errorMessage); + res.status(404).send({message: errorMessage}); return; } - if (exchange.state !== "active") { + if(exchange.state !== 'active') { const errorMessage = `Exchange in state ${exchange.state}`; logUtils.presentationError(rp?.clientId, exchange.id, errorMessage); res.status(400).send(errorMessage); @@ -231,8 +276,8 @@ export class DCApiWorkflowService extends BaseWorkflowService { } const results = await this.dcApi.submit_response( - exchange.variables.dcApiSession.id, - exchange.variables.dcApiSession.client_secret, + exchange.variables.dcApiSession.session_creation_response.id, + exchange.variables.dcApiSession.session_creation_response.client_secret, req.body, ); @@ -240,12 +285,12 @@ export class DCApiWorkflowService extends BaseWorkflowService { const oidcCode = await createId(); await database.collections.Exchanges.updateOne( - { id: exchange.id }, + {id: exchange.id}, { $set: { - state: "complete", - step: "verification", - "oidc.code": oidcCode, + state: 'complete', + step: 'verification', + 'oidc.code': oidcCode, variables: { ...exchange.variables, dcApiResponse: results, @@ -257,27 +302,24 @@ export class DCApiWorkflowService extends BaseWorkflowService { }, ); + // Get the updated exchange to return to frontend with OIDC code + const updatedExchange = await database.collections.Exchanges.findOne({ + id: exchange.id, + }); + // Send callback to relying party if configured - if (rp.workflow.callback) { - const { sendCallback } = await import("../callback.js"); + if(rp.workflow.callback) { + const {sendCallback} = await import('../callback.js'); const callbackSuccess = await sendCallback( rp.workflow, - { - ...exchange, - variables: { ...exchange.variables, dcApiResponse: results }, - }, - "verification", + updatedExchange, + 'verification', ); - if (!callbackSuccess) { - logger.warn("Failed to send callback to relying party"); + if(!callbackSuccess) { + logger.warn('Failed to send callback to relying party'); } } - // Get the updated exchange to return to frontend with OIDC code - const updatedExchange = await database.collections.Exchanges.findOne({ - id: exchange.id, - }); - // Return both results and updated exchange with OIDC code res.send({ ...results, @@ -287,9 +329,8 @@ export class DCApiWorkflowService extends BaseWorkflowService { state: updatedExchange.state, }, }); - } catch (error) { - logUtils.presentationError(rp?.clientId, exchange.id, error.message); - logger.error(error.message, { error }); + } catch(error) { + logger.error(error.message, {error}); res.sendStatus(500); } } diff --git a/package.json b/package.json index 47cfeb8..ee92b4e 100644 --- a/package.json +++ b/package.json @@ -94,7 +94,7 @@ "vue-material-design-icons": "^5.3.0", "vue-router": "^4.3.0", "x25519-key-agreement-2020-context": "^1.0.0", - "@spruceid/opencred-dc-api": "^0.1.2" + "@spruceid/opencred-dc-api": "^0.1.3" }, "devDependencies": { "@bedrock/test": "^8.2.0", diff --git a/web/components/OID4VPView.vue b/web/components/OID4VPView.vue index 96e97ce..d34623f 100644 --- a/web/components/OID4VPView.vue +++ b/web/components/OID4VPView.vue @@ -61,40 +61,6 @@ onMounted(() => { } }); -async function dcApi() { - console.log("Clicked dc api button"); - if (typeof window.DigitalCredential !== "undefined") { - console.log("Making authentication request"); - const { data: response } = await httpClient.get( - `/workflows/${props.exchangeData.workflowId}` + - `/exchanges/${props.exchangeData.id}` + - `/dc-api/request`, - { - headers: { - Authorization: `Bearer ${props.exchangeData.accessToken}`, - }, - }, - ); - - console.log("DC API response:", response); - - const controller = new AbortController(); - const { protocol, data } = await navigator.credentials - .get({ - signal: controller.signal, - mediation: "required", - digital: response, - }) - .then(console.log); - } else { - console.log( - new Error( - "The Digital Credentials API is disabled or not supported in this browser.", - ), - ); - } -} - async function appOpened() { const { location } = window; const searchParams = new URLSearchParams(location.search); From 9acbbabd269dc19d71536a90742527d9c23a2363 Mon Sep 17 00:00:00 2001 From: Ryan Tate Date: Mon, 6 Oct 2025 20:00:28 -0700 Subject: [PATCH 06/12] add dc-api workflow to combined example config Signed-off-by: Ryan Tate --- configs/combined.example.yaml | 41 ++++++++++++++++++++++++++++++++ lib/workflows/dc-api-workflow.js | 8 ++----- 2 files changed, 43 insertions(+), 6 deletions(-) diff --git a/configs/combined.example.yaml b/configs/combined.example.yaml index df89003..d934525 100644 --- a/configs/combined.example.yaml +++ b/configs/combined.example.yaml @@ -149,6 +149,47 @@ app: steps: default: acceptedCredentialType: "TODO" # required + - name: "DC API Test App" + clientId: "opencred" + clientSecret: "opencred" + description: "Relying Party DC API Test App" + icon: "https://placekitten.com/200/200" + backgroundImage: "https://placekitten.com/800/300" + idTokenExpirySeconds: 3600 + brand: + cta: "#0B669D" + primary: "#045199" + header: "#0979c4" + redirectUri: "http://0.0.0.0:8080/realms/OpenCred/broker/oauth2/endpoint" + scopes: + - name: "openid" + description: "Open ID Connect" + claims: + - name: "email" + path: "email" + workflow: + id: 235bbcc4-f0b4-4eb3-8a3b-0006ae17462c + type: dc-api + submissionEndpoint: "" + referenceEndpoint: "" + baseUrl: https://cb1e46108f7d.ngrok-free.app/ + clientSecret: "z1AanFEw6Dk5x2QGfz7B32X9xSDSatz6XEXCATtNvSxBAcP" + steps: + initiated: + callback: + complete: + callback: + dcApiRequest: > + { + "namespaces": { + "org.iso.18013.5.1": [ + "family_name", + "given_name", + "document_number" + ] + }, + "origin": "https://cb1e46108f7d.ngrok-free.app" + } - name: "load-test" clientId: "load-test" clientSecret: "DeepestAndDarkest" diff --git a/lib/workflows/dc-api-workflow.js b/lib/workflows/dc-api-workflow.js index f3bbeba..48f198c 100644 --- a/lib/workflows/dc-api-workflow.js +++ b/lib/workflows/dc-api-workflow.js @@ -52,12 +52,11 @@ export class DCApiWorkflowService extends BaseWorkflowService { ); const encoder = new TextEncoder(); - const submissionEndpoint = ''; - const referenceEndpoint = ''; + const submissionEndpoint = rp.workflow.submissionEndpoint || ''; + const referenceEndpoint = rp.workflow.referenceEndpoint || ''; const oid4vpSessionStore = new JsOid4VpSessionStore({ async initiate(session) { - console.log('Initiating OID4VP session'); // NOTE: creating a new exchange for the oid4vp session // within this callback function versus // `createWorkflowSpecificExchange` method given constraints @@ -98,8 +97,6 @@ export class DCApiWorkflowService extends BaseWorkflowService { } }, async updateStatus(uuid, status) { - console.log(`Updating OID4VP session status to ${status}`); - // Update session status await database.collections.Exchanges.updateOne( { 'variables.oid4vpSession.uuid': uuid, @@ -108,7 +105,6 @@ export class DCApiWorkflowService extends BaseWorkflowService { ); }, async getSession(uuid) { - console.log(`Fetching OID4VP session for UUID: ${uuid}`); const exchange = await database.collections.Exchanges.findOne( {'variables.oid4vpSession.uuid': uuid}, {projection: {_id: 0, variables: 1}}, From d606fe756ce6616212c518177767438deb12ff15 Mon Sep 17 00:00:00 2001 From: Ryan Tate Date: Mon, 6 Oct 2025 20:02:48 -0700 Subject: [PATCH 07/12] revert formatting changes in config.js Signed-off-by: Ryan Tate --- configs/config.js | 266 +++++++++++++++++++++++----------------------- 1 file changed, 133 insertions(+), 133 deletions(-) diff --git a/configs/config.js b/configs/config.js index 228ae53..9f3e537 100644 --- a/configs/config.js +++ b/configs/config.js @@ -5,54 +5,54 @@ * SPDX-License-Identifier: BSD-3-Clause */ -import * as bedrock from "@bedrock/core"; -import { fileURLToPath } from "node:url"; -import { klona } from "klona"; -import path from "node:path"; -import "dotenv/config"; -import "@bedrock/views"; - -import { applyRpDefaults } from "./configUtils.js"; -import { combineTranslations } from "./translation.js"; -import { logger } from "../lib/logger.js"; - -const { config } = bedrock; +import * as bedrock from '@bedrock/core'; +import {fileURLToPath} from 'node:url'; +import {klona} from 'klona'; +import path from 'node:path'; +import 'dotenv/config'; +import '@bedrock/views'; + +import {applyRpDefaults} from './configUtils.js'; +import {combineTranslations} from './translation.js'; +import {logger} from '../lib/logger.js'; + +const {config} = bedrock; config.opencred = {}; const __dirname = path.dirname(fileURLToPath(import.meta.url)); -const rootPath = path.join(__dirname, ".."); +const rootPath = path.join(__dirname, '..'); -bedrock.events.on("bedrock-cli.parsed", async () => { - await import(path.join(config.paths.config, "paths.js")); - await import(path.join(config.paths.config, "core.js")); +bedrock.events.on('bedrock-cli.parsed', async () => { + await import(path.join(config.paths.config, 'paths.js')); + await import(path.join(config.paths.config, 'core.js')); }); -bedrock.events.on("bedrock.configure", async () => { - await import(path.join(config.paths.config, "express.js")); - await import(path.join(config.paths.config, "server.js")); - await import(path.join(config.paths.config, "database.js")); - await import(path.join(config.paths.config, "https-agent.js")); - await import(path.join(config.paths.config, "authorization.js")); +bedrock.events.on('bedrock.configure', async () => { + await import(path.join(config.paths.config, 'express.js')); + await import(path.join(config.paths.config, 'server.js')); + await import(path.join(config.paths.config, 'database.js')); + await import(path.join(config.paths.config, 'https-agent.js')); + await import(path.join(config.paths.config, 'authorization.js')); }); config.views.bundle.packages.push({ - path: path.join(rootPath, "web"), - manifest: path.join(rootPath, "web", "manifest.json"), + path: path.join(rootPath, 'web'), + manifest: path.join(rootPath, 'web', 'manifest.json'), }); -config["bedrock-webpack"].configs.push({ +config['bedrock-webpack'].configs.push({ module: { rules: [ { test: /\.pcss$/i, - include: path.resolve(__dirname, "..", "web"), + include: path.resolve(__dirname, '..', 'web'), use: [ - "style-loader", - "css-loader", + 'style-loader', + 'css-loader', { - loader: "postcss-loader", + loader: 'postcss-loader', options: { postcssOptions: { - plugins: ["postcss-preset-env", "tailwindcss", "autoprefixer"], + plugins: ['postcss-preset-env', 'tailwindcss', 'autoprefixer'], }, }, }, @@ -62,8 +62,8 @@ config["bedrock-webpack"].configs.push({ }, }); -bedrock.events.on("bedrock.init", async () => { - const { opencred } = config; +bedrock.events.on('bedrock.init', async () => { + const {opencred} = config; /** * @typedef {Object} VcApiWorkflow * @property {'vc-api'} type - The type of the workflow. @@ -157,7 +157,7 @@ bedrock.events.on("bedrock.init", async () => { * record in seconds. (Default/max 900) */ - const availableExchangeProtocols = ["openid4vp", "chapi", "dc-api"]; + const availableExchangeProtocols = ['openid4vp', 'chapi', 'dc-api']; /** * A list of exchange protocols in use by OpenCred * exchangeProtocols: ['openid4vp', 'chapi'] @@ -186,8 +186,8 @@ bedrock.events.on("bedrock.init", async () => { ), ); - if ( - !opencred.options.exchangeProtocols.every((el) => + if( + !opencred.options.exchangeProtocols.every(el => availableExchangeProtocols.includes(el), ) ) { @@ -198,7 +198,7 @@ bedrock.events.on("bedrock.init", async () => { ); } - if (!opencred.relyingParties) { + if(!opencred.relyingParties) { opencred.relyingParties = []; } /** @@ -209,78 +209,78 @@ bedrock.events.on("bedrock.init", async () => { // Workflow types const WorkflowType = { - VcApi: "vc-api", - Native: "native", - MicrosoftEntraVerifiedId: "microsoft-entra-verified-id", - DcApi: "dc-api", + VcApi: 'vc-api', + Native: 'native', + MicrosoftEntraVerifiedId: 'microsoft-entra-verified-id', + DcApi: 'dc-api', }; const WorkFlowTypes = Object.values(WorkflowType); - const validateRelyingParty = (rp) => { - if (!rp.clientId) { - throw new Error("clientId is required for each configured relyingParty."); + const validateRelyingParty = rp => { + if(!rp.clientId) { + throw new Error('clientId is required for each configured relyingParty.'); } - if (!rp.clientSecret) { + if(!rp.clientSecret) { throw new Error(`clientSecret is required in ${rp.clientId}.`); } // Use redirectUri for proxy of OIDC being enabled or not - if (rp.redirectUri) { + if(rp.redirectUri) { // if redirectUri doesn't match http or https throw an error - if (!rp.redirectUri.match(/^https?:\/\//)) { + if(!rp.redirectUri.match(/^https?:\/\//)) { throw new Error(`redirectUri must be a URI in client ${rp.clientId}.`); } - if (!rp.scopes || !Array.isArray(rp.scopes)) { + if(!rp.scopes || !Array.isArray(rp.scopes)) { throw new Error( `An array of scopes must be defined in client ${rp.clientId}.`, ); } - if (!rp.scopes.map((s) => s.name).includes("openid")) { + if(!rp.scopes.map(s => s.name).includes('openid')) { throw new Error(`scopes in client ${rp.clientId} must include openid.`); } - if (!rp.idTokenExpirySeconds) { + if(!rp.idTokenExpirySeconds) { rp.idTokenExpirySeconds = 3600; } } }; - const validateWorkflow = (rp) => { - if (!rp.workflow) { - throw new Error("workflow must be defined."); + const validateWorkflow = rp => { + if(!rp.workflow) { + throw new Error('workflow must be defined.'); } - if (rp.workflow.type === WorkflowType.VcApi) { - if (!rp.workflow.baseUrl?.startsWith("http")) { + if(rp.workflow.type === WorkflowType.VcApi) { + if(!rp.workflow.baseUrl?.startsWith('http')) { throw new Error( - "workflow baseUrl must be defined. This tool uses a VC-API exchange" + + 'workflow baseUrl must be defined. This tool uses a VC-API exchange' + ` endpoint to communicate with wallets. (client: ${rp.clientId})`, ); - } else if (typeof rp.workflow.capability !== "string") { + } else if(typeof rp.workflow.capability !== 'string') { throw new Error( `workflow capability must be defined. (client: ${rp.clientId})`, ); - } else if ( + } else if( !rp.workflow.clientSecret || - typeof rp.workflow.clientSecret !== "string" || + typeof rp.workflow.clientSecret !== 'string' || rp.workflow.clientSecret.length < 1 ) { throw new Error( `workflow clientSecret must be defined. (client: ${rp.clientId})`, ); } - } else if (rp.workflow.type === WorkflowType.Native) { - if (!rp.workflow.steps || Object.keys(rp.workflow.steps).length === 0) { + } else if(rp.workflow.type === WorkflowType.Native) { + if(!rp.workflow.steps || Object.keys(rp.workflow.steps).length === 0) { throw new Error( `workflow must have at least 1 step. (client: ${rp.clientId})`, ); } - if (!rp.workflow.initialStep) { + if(!rp.workflow.initialStep) { throw new Error( `workflow initialStep must be set. (client: ${rp.clientId})`, ); } - } else if (rp.workflow.type === WorkflowType.MicrosoftEntraVerifiedId) { + } else if(rp.workflow.type === WorkflowType.MicrosoftEntraVerifiedId) { const { apiBaseUrl, apiLoginBaseUrl, @@ -292,105 +292,105 @@ bedrock.events.on("bedrock.init", async () => { steps, initialStep, } = rp.workflow; - if (!apiBaseUrl) { + if(!apiBaseUrl) { throw new Error( `apiBaseUrl is missing for workflow in client ${rp.clientId}.`, ); } - if (!apiLoginBaseUrl) { + if(!apiLoginBaseUrl) { throw new Error( `apiLoginBaseUrl is missing for workflow in client ${rp.clientId}.`, ); } - if (!apiClientId) { + if(!apiClientId) { throw new Error( `apiClientId is missing for workflow in client ${rp.clientId}.`, ); } - if (!apiClientSecret) { + if(!apiClientSecret) { throw new Error( `apiClientSecret is missing for workflow in client ${rp.clientId}.`, ); } - if (!apiTenantId) { + if(!apiTenantId) { throw new Error( `apiTenantId is missing for workflow in client ${rp.clientId}.`, ); } - if (!verifierDid) { + if(!verifierDid) { throw new Error( `verifierDid is missing for workflow in client ${rp.clientId}.`, ); } - if (!verifierName) { + if(!verifierName) { throw new Error( `verifierName is missing for workflow in client ${rp.clientId}.`, ); } - if (!steps) { + if(!steps) { throw new Error( `steps is missing for workflow in client ${rp.clientId}.`, ); } - if (!initialStep) { + if(!initialStep) { throw new Error( `initialStep is missing for workflow in client ${rp.clientId}.`, ); } - const { acceptedCredentialType } = steps[initialStep]; - if (!acceptedCredentialType) { + const {acceptedCredentialType} = steps[initialStep]; + if(!acceptedCredentialType) { throw new Error( `acceptedCredentialType is missing for workflow in ${rp.clientId}.`, ); } - } else if (rp.workflow.type === WorkflowType.DcApi) { - console.log("Loading DC API Workflow"); + } else if(rp.workflow.type === WorkflowType.DcApi) { + console.log('Loading DC API Workflow'); } else { throw new Error( - "workflow type must be one of the following values: " + - `${WorkFlowTypes.map((v) => `'${v}'`).join(", ")}.`, + 'workflow type must be one of the following values: ' + + `${WorkFlowTypes.map(v => `'${v}'`).join(', ')}.`, ); } }; // If relyingParties is not an array, throw an error - if (!Array.isArray(configRPs)) { - throw new Error("Configuration relyingParties must be an array."); + if(!Array.isArray(configRPs)) { + throw new Error('Configuration relyingParties must be an array.'); } - opencred.defaultLanguage = opencred.defaultLanguage || "en"; + opencred.defaultLanguage = opencred.defaultLanguage || 'en'; opencred.translations = combineTranslations(opencred.translations || {}); const defaultBrand = opencred.defaultBrand ?? { - cta: "#006847", - primary: "#008f5a", - header: "#004225", + cta: '#006847', + primary: '#008f5a', + header: '#004225', }; const validateDidWeb = () => { return { mainEnabled: opencred.didWeb?.mainEnabled, linkageEnabled: opencred.didWeb?.linkageEnabled, - mainDocument: JSON.parse(opencred.didWeb?.mainDocument ?? "{}"), - linkageDocument: JSON.parse(opencred.didWeb?.linkageDocument ?? "{}"), + mainDocument: JSON.parse(opencred.didWeb?.mainDocument ?? '{}'), + linkageDocument: JSON.parse(opencred.didWeb?.linkageDocument ?? '{}'), }; }; opencred.didWeb = validateDidWeb(); const validateSigningKeys = () => { - if (!opencred.signingKeys) { + if(!opencred.signingKeys) { return []; } - opencred.signingKeys.forEach((sk) => { - if (!sk.type) { - throw new Error("Each signingKey must have a type."); + opencred.signingKeys.forEach(sk => { + if(!sk.type) { + throw new Error('Each signingKey must have a type.'); } - if (!Array.isArray(sk.purpose) || !sk.purpose?.length) { - throw new Error("Each signingKey must have at least one purpose."); + if(!Array.isArray(sk.purpose) || !sk.purpose?.length) { + throw new Error('Each signingKey must have at least one purpose.'); } - if (sk.type == "ES256" && (!sk.privateKeyPem || !sk.publicKeyPem)) { + if(sk.type == 'ES256' && (!sk.privateKeyPem || !sk.publicKeyPem)) { throw new Error( - "Each ES256 signingKey must have a privateKeyPem and publicKeyPem.", + 'Each ES256 signingKey must have a privateKeyPem and publicKeyPem.', ); } }); @@ -402,7 +402,7 @@ bedrock.events.on("bedrock.init", async () => { * A list of relying parties (connected apps or workflows) in use by OpenCred * @type {RelyingParty[]} */ - opencred.relyingParties = configRPs.map((rp) => { + opencred.relyingParties = configRPs.map(rp => { const app = applyRpDefaults(configRPs, rp); validateRelyingParty(app); validateWorkflow(app); @@ -419,17 +419,17 @@ bedrock.events.on("bedrock.init", async () => { /** * A list of trusted issuers */ - const validateTrustedCredentialIssuers = (scope) => { - if (!scope.trustedCredentialIssuers) { + const validateTrustedCredentialIssuers = scope => { + if(!scope.trustedCredentialIssuers) { return; } - if (!Array.isArray(scope.trustedCredentialIssuers)) { - throw new Error("trustedCredentialIssuers must be an array"); + if(!Array.isArray(scope.trustedCredentialIssuers)) { + throw new Error('trustedCredentialIssuers must be an array'); } - for (const issuer of scope.trustedCredentialIssuers) { - if (typeof issuer !== "string") { + for(const issuer of scope.trustedCredentialIssuers) { + if(typeof issuer !== 'string') { throw new Error( - "Each issuer in trustedCredentialIssuers " + "must be a string", + 'Each issuer in trustedCredentialIssuers ' + 'must be a string', ); } } @@ -437,13 +437,13 @@ bedrock.events.on("bedrock.init", async () => { const applyDefaultTrustedCredentialIssuers = () => { opencred.trustedCredentialIssuers = opencred.trustedCredentialIssuers ?? []; validateTrustedCredentialIssuers(opencred); - for (const rp of opencred.relyingParties) { + for(const rp of opencred.relyingParties) { rp.trustedCredentialIssuers = rp.trustedCredentialIssuers ?? []; validateTrustedCredentialIssuers(rp); rp.trustedCredentialIssuers = - rp.trustedCredentialIssuers.length === 0 - ? opencred.trustedCredentialIssuers - : rp.trustedCredentialIssuers; + rp.trustedCredentialIssuers.length === 0 ? + opencred.trustedCredentialIssuers : + rp.trustedCredentialIssuers; } }; applyDefaultTrustedCredentialIssuers(); @@ -452,40 +452,40 @@ bedrock.events.on("bedrock.init", async () => { * Prepare a list of trusted root certificates */ const applyCaStoreDefaults = () => { - opencred.caStore = (opencred.caStore ?? []).map((cert) => cert.pem); + opencred.caStore = (opencred.caStore ?? []).map(cert => cert.pem); }; applyCaStoreDefaults(); /** * reCAPTCHA configuration */ - if (!opencred.reCaptcha) { + if(!opencred.reCaptcha) { opencred.reCaptcha = {}; } - if (!opencred.reCaptcha.pages) { + if(!opencred.reCaptcha.pages) { opencred.reCaptcha.pages = []; } opencred.reCaptcha.enable = opencred.reCaptcha.enable === true; const availableReCaptchaVersions = [2, 3]; const validateReCaptcha = () => { - if (opencred.reCaptcha.enable) { - if ( + if(opencred.reCaptcha.enable) { + if( !opencred.reCaptcha.version || !opencred.reCaptcha.siteKey || !opencred.reCaptcha.secretKey ) { throw new Error( 'When the "reCaptcha.enable" config value is "true", ' + - "the following config values must also be provided: " + + 'the following config values must also be provided: ' + '"reCaptcha.version", "reCaptcha.siteKey", and "reCaptcha.secretKey"', ); } - if (!availableReCaptchaVersions.includes(opencred.reCaptcha.version)) { + if(!availableReCaptchaVersions.includes(opencred.reCaptcha.version)) { throw new Error( 'The config value of "reCaptcha.version" must be ' + - "one of the following values: " + - availableReCaptchaVersions.map((v) => `"${v}"`).join(", "), + 'one of the following values: ' + + availableReCaptchaVersions.map(v => `"${v}"`).join(', '), ); } } @@ -495,10 +495,10 @@ bedrock.events.on("bedrock.init", async () => { /** * Auditing configuration */ - if (!opencred.audit) { + if(!opencred.audit) { opencred.audit = {}; } - if (!opencred.audit.fields) { + if(!opencred.audit.fields) { opencred.audit.fields = []; } opencred.audit.enable = opencred.audit.enable === true; @@ -515,54 +515,54 @@ bedrock.events.on("bedrock.init", async () => { * @property {string} options - Options for dropdown fields. */ - const requiredAuditFieldKeys = ["type", "id", "name", "path", "required"]; - const auditFieldTypes = ["text", "number", "date", "dropdown"]; + const requiredAuditFieldKeys = ['type', 'id', 'name', 'path', 'required']; + const auditFieldTypes = ['text', 'number', 'date', 'dropdown']; const validateAuditFields = () => { - if (opencred.audit.fields.length === 0) { + if(opencred.audit.fields.length === 0) { return; } - if (!Array.isArray(opencred.audit.fields)) { + if(!Array.isArray(opencred.audit.fields)) { throw new Error('The "audit.fields" config value must be an array.'); } - for (const field of opencred.audit.fields) { - if ( - !requiredAuditFieldKeys.every((f) => Object.keys(field).includes(f)) + for(const field of opencred.audit.fields) { + if( + !requiredAuditFieldKeys.every(f => Object.keys(field).includes(f)) ) { throw new Error( 'Each object in "audit.fields" must have the ' + - "following keys: " + - requiredAuditFieldKeys.map((k) => `"${k}"`).join(", "), + 'following keys: ' + + requiredAuditFieldKeys.map(k => `"${k}"`).join(', '), ); } - if (!auditFieldTypes.includes(field.type)) { + if(!auditFieldTypes.includes(field.type)) { throw new Error( 'Each object in "audit.fields" must have one of the ' + - "following types: " + - auditFieldTypes.map((t) => `"${t}"`).join(", "), + 'following types: ' + + auditFieldTypes.map(t => `"${t}"`).join(', '), ); } } const auditFieldsHaveUniqueIds = klona(opencred.audit.fields) - .map((k) => k.id) + .map(k => k.id) .sort() .reduce( (unique, currentId, currentIndex, ids) => unique && currentId !== ids[currentIndex - 1], true, ); - if (!auditFieldsHaveUniqueIds) { + if(!auditFieldsHaveUniqueIds) { throw new Error('Each object in "audit.fields" must have a unique "id".'); } const auditFieldsHaveUniquePaths = klona(opencred.audit.fields) - .map((k) => k.id) + .map(k => k.id) .sort() .reduce( (unique, currentPath, currentIndex, paths) => unique && currentPath !== paths[currentIndex - 1], true, ); - if (!auditFieldsHaveUniquePaths) { + if(!auditFieldsHaveUniquePaths) { throw new Error( 'Each object in "audit.fields" must have ' + 'a unique "path".', ); @@ -570,5 +570,5 @@ bedrock.events.on("bedrock.init", async () => { }; validateAuditFields(); - logger.info("OpenCred Config Successfully Validated."); + logger.info('OpenCred Config Successfully Validated.'); }); From b903bf5739822bfd35deb9be10fc64162e043c0d Mon Sep 17 00:00:00 2001 From: Ryan Tate Date: Mon, 6 Oct 2025 20:04:04 -0700 Subject: [PATCH 08/12] fix linting errors Signed-off-by: Ryan Tate --- configs/config.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/configs/config.js b/configs/config.js index 9f3e537..6479b66 100644 --- a/configs/config.js +++ b/configs/config.js @@ -478,7 +478,8 @@ bedrock.events.on('bedrock.init', async () => { throw new Error( 'When the "reCaptcha.enable" config value is "true", ' + 'the following config values must also be provided: ' + - '"reCaptcha.version", "reCaptcha.siteKey", and "reCaptcha.secretKey"', + '"reCaptcha.version", "reCaptcha.siteKey", ' + + 'and "reCaptcha.secretKey"', ); } if(!availableReCaptchaVersions.includes(opencred.reCaptcha.version)) { From ff3cc39e87f5ec2c7ad4a6e2df573618bbff9da9 Mon Sep 17 00:00:00 2001 From: Ryan Tate Date: Mon, 6 Oct 2025 20:05:34 -0700 Subject: [PATCH 09/12] revert combined config yaml formatting changes Signed-off-by: Ryan Tate --- configs/combined.example.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/configs/combined.example.yaml b/configs/combined.example.yaml index d934525..ff2ea7c 100644 --- a/configs/combined.example.yaml +++ b/configs/combined.example.yaml @@ -5,7 +5,7 @@ app: # server: - # baseUri: https://evil-cows-return.loca.lt + # baseUri: https://evil-cows-return.loca.lt opencred: caStore: - pem: | From 31cd65402285b262b096cee8f45324752d0153aa Mon Sep 17 00:00:00 2001 From: Ryan Tate Date: Mon, 6 Oct 2025 20:22:46 -0700 Subject: [PATCH 10/12] use create session storage methods Signed-off-by: Ryan Tate --- configs/combined.example.yaml | 2 +- lib/workflows/dc-api-workflow.js | 223 +++++++++++++++---------------- 2 files changed, 112 insertions(+), 113 deletions(-) diff --git a/configs/combined.example.yaml b/configs/combined.example.yaml index ff2ea7c..d934525 100644 --- a/configs/combined.example.yaml +++ b/configs/combined.example.yaml @@ -5,7 +5,7 @@ app: # server: - # baseUri: https://evil-cows-return.loca.lt + # baseUri: https://evil-cows-return.loca.lt opencred: caStore: - pem: | diff --git a/lib/workflows/dc-api-workflow.js b/lib/workflows/dc-api-workflow.js index 48f198c..dc7ed1f 100644 --- a/lib/workflows/dc-api-workflow.js +++ b/lib/workflows/dc-api-workflow.js @@ -55,122 +55,14 @@ export class DCApiWorkflowService extends BaseWorkflowService { const submissionEndpoint = rp.workflow.submissionEndpoint || ''; const referenceEndpoint = rp.workflow.referenceEndpoint || ''; - const oid4vpSessionStore = new JsOid4VpSessionStore({ - async initiate(session) { - // NOTE: creating a new exchange for the oid4vp session - // within this callback function versus - // `createWorkflowSpecificExchange` method given constraints - // in how the session is managed internally. Rather than using - // the dc-api exchange created during the - // `createWorkflowSpecificExchange` execution, this oid4vp - // session store creates a secondary exchange, managed exclusively - // within these callbacks. The variables object provides the - // main details of the session under the `oid4vpSession`, while the - // other fields are provided to maintain compatbility - // with the existing `Exchange` database model. - try { - const duration = config.opencred.options.recordExpiresDurationMs; - const ttl = 1000 * config.opencred.options.exchangeTtlSeconds; - const gracePeriod = 1000 * 60; - const createdAt = new Date(); - - await database.collections.Exchanges.insertOne({ - id: await createId(), - challenge: await createId(), - workflowId: '', - state: 'pending', - sequence: 0, - step: 'initiated', - ttl, - createdAt, - recordExpiresAt: new Date( - createdAt.getTime() + Math.max(ttl + gracePeriod, duration), - ), - variables: { - oid4vpSession: session, - }, - oidc: '', - accessToken: '', - }); - } catch(e) { - console.error('Failed to create exchange:', e); - } - }, - async updateStatus(uuid, status) { - await database.collections.Exchanges.updateOne( - { - 'variables.oid4vpSession.uuid': uuid, - }, - {$set: {'variables.oid4vpSession.status': status}}, - ); - }, - async getSession(uuid) { - const exchange = await database.collections.Exchanges.findOne( - {'variables.oid4vpSession.uuid': uuid}, - {projection: {_id: 0, variables: 1}}, - ); - - if(!exchange || !exchange.variables.oid4vpSession) { - throw new Error(`OID4VP Session not found for UUID: ${uuid}`); - } - - return exchange.variables.oid4vpSession; - }, - async removeSession(uuid) { - await database.collections.Exchanges.deleteOne({ - 'variables.oid4vpSession.uuid': uuid, - }); - }, - }); - - // const dcApi = new DcApi(); const dcApi = await DcApi.new( sk.privateKeyPem, rp?.workflow?.baseUrl, submissionEndpoint, referenceEndpoint, encoder.encode(config.opencred.caStore[0]), - // JsOid4VpSessionStore.createMemoryStore(), - oid4vpSessionStore, - // DC API Session - { - newSession: async (/* sessionId, session */) => { - // NOTE: This callback is handled within the - // `createWorkflowSpecificExchange` when `create_new_session` - // is called. The session is stored directly within the exchange. - }, - getSession: async (id /*, clientSecret*/) => { - console.log(`Fetching DC API session for ID: ${id}`); - const exchange = await database.collections.Exchanges.findOne( - {'variables.dcApiSession.session_creation_response.id': id}, - {projection: {_id: 0, variables: 1}}, - ); - return exchange?.variables?.dcApiSession.session || null; - }, - getSessionUnauthenticated: async id => { - console.log(`Fetching unauthenticated DC API session for ID: ${id}`); - // return this.dcApiSessionCache.get(id) || null; - const exchange = await database.collections.Exchanges.findOne( - {'variables.dcApiSession.session_creation_response.id': id}, - {projection: {_id: 0, variables: 1}}, - ); - return exchange?.variables?.dcApiSession.session || null; - }, - updateSession: async (sessionId, session) => { - console.log(`Updating DC API session for ID: ${sessionId}`); - await database.collections.Exchanges.updateOne( - { - 'variables.dcApiSession.session_creation_response.id': sessionId, - }, - {$set: {'variables.dcApiSession.session': session}}, - ); - }, - removeSession: async sessionId => { - await database.collections.Exchanges.deleteOne({ - 'variables.dcApiSession.session_creation_response.id': sessionId, - }); - }, - }, + this.createOID4VPSessionStore(), + this.createDCAPISessionStore(), ); return dcApi; @@ -285,7 +177,7 @@ export class DCApiWorkflowService extends BaseWorkflowService { { $set: { state: 'complete', - step: 'verification', + step: 'complete', 'oidc.code': oidcCode, variables: { ...exchange.variables, @@ -309,7 +201,7 @@ export class DCApiWorkflowService extends BaseWorkflowService { const callbackSuccess = await sendCallback( rp.workflow, updatedExchange, - 'verification', + 'complete', ); if(!callbackSuccess) { logger.warn('Failed to send callback to relying party'); @@ -330,4 +222,111 @@ export class DCApiWorkflowService extends BaseWorkflowService { res.sendStatus(500); } } + + createOID4VPSessionStore() { + return new JsOid4VpSessionStore({ + async initiate(session) { + // NOTE: creating a new exchange for the oid4vp session + // within this callback function versus + // `createWorkflowSpecificExchange` method given constraints + // in how the session is managed internally. Rather than using + // the dc-api exchange created during the + // `createWorkflowSpecificExchange` execution, this oid4vp + // session store creates a secondary exchange, managed exclusively + // within these callbacks. The variables object provides the + // main details of the session under the `oid4vpSession`, while the + // other fields are provided to maintain compatbility + // with the existing `Exchange` database model. + try { + const duration = config.opencred.options.recordExpiresDurationMs; + const ttl = 1000 * config.opencred.options.exchangeTtlSeconds; + const gracePeriod = 1000 * 60; + const createdAt = new Date(); + + await database.collections.Exchanges.insertOne({ + id: await createId(), + challenge: await createId(), + workflowId: '', + state: 'pending', + sequence: 0, + step: 'initiated', + ttl, + createdAt, + recordExpiresAt: new Date( + createdAt.getTime() + Math.max(ttl + gracePeriod, duration), + ), + variables: { + oid4vpSession: session, + }, + oidc: '', + accessToken: '', + }); + } catch(e) { + console.error('Failed to create exchange:', e); + } + }, + async updateStatus(uuid, status) { + await database.collections.Exchanges.updateOne( + { + 'variables.oid4vpSession.uuid': uuid, + }, + {$set: {'variables.oid4vpSession.status': status}}, + ); + }, + async getSession(uuid) { + const exchange = await database.collections.Exchanges.findOne( + {'variables.oid4vpSession.uuid': uuid}, + {projection: {_id: 0, variables: 1}}, + ); + + if(!exchange || !exchange.variables.oid4vpSession) { + throw new Error(`OID4VP Session not found for UUID: ${uuid}`); + } + + return exchange.variables.oid4vpSession; + }, + async removeSession(uuid) { + await database.collections.Exchanges.deleteOne({ + 'variables.oid4vpSession.uuid': uuid, + }); + }, + }); + } + + createDCAPISessionStore() { + return { + newSession: async (/* sessionId, session */) => { + // NOTE: This callback is handled within the + // `createWorkflowSpecificExchange` when `create_new_session` + // is called. The session is stored directly within the exchange. + }, + getSession: async (id /*, clientSecret*/) => { + const exchange = await database.collections.Exchanges.findOne( + {'variables.dcApiSession.session_creation_response.id': id}, + {projection: {_id: 0, variables: 1}}, + ); + return exchange?.variables?.dcApiSession.session || null; + }, + getSessionUnauthenticated: async id => { + const exchange = await database.collections.Exchanges.findOne( + {'variables.dcApiSession.session_creation_response.id': id}, + {projection: {_id: 0, variables: 1}}, + ); + return exchange?.variables?.dcApiSession.session || null; + }, + updateSession: async (sessionId, session) => { + await database.collections.Exchanges.updateOne( + { + 'variables.dcApiSession.session_creation_response.id': sessionId, + }, + {$set: {'variables.dcApiSession.session': session}}, + ); + }, + removeSession: async sessionId => { + await database.collections.Exchanges.deleteOne({ + 'variables.dcApiSession.session_creation_response.id': sessionId, + }); + }, + }; + } } From 086ae02c58bb867562f331d61d3aeb8db921288b Mon Sep 17 00:00:00 2001 From: Ryan Tate Date: Mon, 6 Oct 2025 20:38:02 -0700 Subject: [PATCH 11/12] use 'default' as step for verification results Signed-off-by: Ryan Tate --- common/jwt.js | 31 +++++++++++++++---------------- lib/workflows/dc-api-workflow.js | 7 +++---- 2 files changed, 18 insertions(+), 20 deletions(-) diff --git a/common/jwt.js b/common/jwt.js index 9c7fc34..cf2da10 100644 --- a/common/jwt.js +++ b/common/jwt.js @@ -48,22 +48,29 @@ export const jwtFromExchange = async (exchange, rp) => { kid: signingKey.id, }; + if(!exchange.variables?.results) { + return null; + } + + const stepResultKey = Object.keys(exchange.variables.results).find( + v => v == exchange.step, + ); + const stepResults = exchange.variables.results[stepResultKey]; + // Handle DC API workflow differently - if(rp.workflow?.type === 'dc-api' && exchange.variables?.dcApiResponse) { + if(rp.workflow?.type === 'dc-api' && !!stepResults) { try { const now = Math.floor(Date.now() / 1000); - const dcApiResponse = exchange.variables.dcApiResponse; // Default to 1 hour const expirySeconds = rp.idTokenExpirySeconds || 3600; - const subject = - dcApiResponse.response['org.iso.18013.5.1'].document_number; + const subject = stepResults.response['org.iso.18013.5.1'].document_number; const verified = - dcApiResponse.response.issuer_authentication == 'Valid' && - dcApiResponse.response.device_authentication == 'Valid'; + stepResults.response.issuer_authentication == 'Valid' && + stepResults.response.device_authentication == 'Valid'; - const errors = dcApiResponse.response.errors; + const errors = stepResults.response.errors; const payload = { iss: config.server.baseUri, @@ -73,7 +80,7 @@ export const jwtFromExchange = async (exchange, rp) => { exp: now + expirySeconds, verified, verification_method: 'dc-api', - verified_credentials: dcApiResponse.response, + verified_credentials: stepResults.response, }; if(errors !== null) { @@ -88,14 +95,6 @@ export const jwtFromExchange = async (exchange, rp) => { } } - if(!exchange.variables?.results) { - return null; - } - - const stepResultKey = Object.keys(exchange.variables.results).find( - v => v == exchange.step, - ); - const stepResults = exchange.variables.results[stepResultKey]; const c = jp.query( stepResults, '$.verifiablePresentation.verifiableCredential[0]', diff --git a/lib/workflows/dc-api-workflow.js b/lib/workflows/dc-api-workflow.js index dc7ed1f..5be1b92 100644 --- a/lib/workflows/dc-api-workflow.js +++ b/lib/workflows/dc-api-workflow.js @@ -177,13 +177,12 @@ export class DCApiWorkflowService extends BaseWorkflowService { { $set: { state: 'complete', - step: 'complete', + step: 'default', 'oidc.code': oidcCode, variables: { ...exchange.variables, - dcApiResponse: results, results: { - verification: results, + default: results, }, }, }, @@ -201,7 +200,7 @@ export class DCApiWorkflowService extends BaseWorkflowService { const callbackSuccess = await sendCallback( rp.workflow, updatedExchange, - 'complete', + 'default', ); if(!callbackSuccess) { logger.warn('Failed to send callback to relying party'); From e089c7029b89155a930cb7375d14147dc6f82029 Mon Sep 17 00:00:00 2001 From: Ryan Tate Date: Wed, 22 Oct 2025 07:59:27 -0700 Subject: [PATCH 12/12] unify the exchange session for openid4vp and dc-api sessions Signed-off-by: Ryan Tate --- lib/workflows/dc-api-workflow.js | 38 +++++--------------------------- package.json | 2 +- 2 files changed, 6 insertions(+), 34 deletions(-) diff --git a/lib/workflows/dc-api-workflow.js b/lib/workflows/dc-api-workflow.js index 5be1b92..9432beb 100644 --- a/lib/workflows/dc-api-workflow.js +++ b/lib/workflows/dc-api-workflow.js @@ -225,41 +225,13 @@ export class DCApiWorkflowService extends BaseWorkflowService { createOID4VPSessionStore() { return new JsOid4VpSessionStore({ async initiate(session) { - // NOTE: creating a new exchange for the oid4vp session - // within this callback function versus - // `createWorkflowSpecificExchange` method given constraints - // in how the session is managed internally. Rather than using - // the dc-api exchange created during the - // `createWorkflowSpecificExchange` execution, this oid4vp - // session store creates a secondary exchange, managed exclusively - // within these callbacks. The variables object provides the - // main details of the session under the `oid4vpSession`, while the - // other fields are provided to maintain compatbility - // with the existing `Exchange` database model. try { - const duration = config.opencred.options.recordExpiresDurationMs; - const ttl = 1000 * config.opencred.options.exchangeTtlSeconds; - const gracePeriod = 1000 * 60; - const createdAt = new Date(); - - await database.collections.Exchanges.insertOne({ - id: await createId(), - challenge: await createId(), - workflowId: '', - state: 'pending', - sequence: 0, - step: 'initiated', - ttl, - createdAt, - recordExpiresAt: new Date( - createdAt.getTime() + Math.max(ttl + gracePeriod, duration), - ), - variables: { - oid4vpSession: session, + await database.collections.Exchanges.updateOne( + { + 'variables.dcApiSession.session_creation_response.id': session.id, }, - oidc: '', - accessToken: '', - }); + {$set: {'variables.oid4vpSession': session}}, + ); } catch(e) { console.error('Failed to create exchange:', e); } diff --git a/package.json b/package.json index ee92b4e..009032e 100644 --- a/package.json +++ b/package.json @@ -94,7 +94,7 @@ "vue-material-design-icons": "^5.3.0", "vue-router": "^4.3.0", "x25519-key-agreement-2020-context": "^1.0.0", - "@spruceid/opencred-dc-api": "^0.1.3" + "@spruceid/opencred-dc-api": "^0.2.0" }, "devDependencies": { "@bedrock/test": "^8.2.0",