diff --git a/.eslintrc.cjs b/.eslintrc.cjs index db6e1dd..a43c3bb 100644 --- a/.eslintrc.cjs +++ b/.eslintrc.cjs @@ -28,4 +28,4 @@ module.exports = { 'unicorn/prefer-node-protocol': 'error', 'vue/no-v-html': 'off' } -}; +}; \ No newline at end of file diff --git a/common/jwt.js b/common/jwt.js index af50d43..cf2da10 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,59 @@ export const jwtFromExchange = async (exchange, rp) => { const header = { alg: signingKey.type, typ: 'JWT', - kid: signingKey.id + kid: signingKey.id, }; + 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]; + + // Handle DC API workflow differently + if(rp.workflow?.type === 'dc-api' && !!stepResults) { + try { + const now = Math.floor(Date.now() / 1000); + // Default to 1 hour + const expirySeconds = rp.idTokenExpirySeconds || 3600; + + const subject = stepResults.response['org.iso.18013.5.1'].document_number; + + const verified = + stepResults.response.issuer_authentication == 'Valid' && + stepResults.response.device_authentication == 'Valid'; + + const errors = stepResults.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: stepResults.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; + } + } + const c = jp.query( - stepResults, '$.verifiablePresentation.verifiableCredential[0]' + stepResults, + '$.verifiablePresentation.verifiableCredential[0]', ); if(!c.length) { return null; @@ -65,7 +109,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..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: | @@ -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" @@ -282,12 +323,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 +336,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 +375,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..6479b66 100644 --- a/configs/config.js +++ b/configs/config.js @@ -36,32 +36,30 @@ bedrock.events.on('bedrock.configure', async () => { config.views.bundle.packages.push({ path: path.join(rootPath, 'web'), - manifest: path.join(rootPath, 'web', 'manifest.json') + manifest: path.join(rootPath, 'web', 'manifest.json'), }); 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 () => { @@ -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,25 +167,35 @@ 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) { @@ -203,7 +211,8 @@ bedrock.events.on('bedrock.init', async () => { const WorkflowType = { VcApi: 'vc-api', Native: 'native', - MicrosoftEntraVerifiedId: 'microsoft-entra-verified-id' + MicrosoftEntraVerifiedId: 'microsoft-entra-verified-id', + DcApi: 'dc-api', }; const WorkFlowTypes = Object.values(WorkflowType); @@ -225,7 +234,7 @@ bedrock.events.on('bedrock.init', async () => { 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')) { @@ -245,11 +254,11 @@ bedrock.events.on('bedrock.init', async () => { 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})` + ` endpoint to communicate with wallets. (client: ${rp.clientId})`, ); } 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( !rp.workflow.clientSecret || @@ -257,18 +266,18 @@ bedrock.events.on('bedrock.init', async () => { 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) { 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) { 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) { @@ -281,53 +290,65 @@ bedrock.events.on('bedrock.init', async () => { verifierDid, verifierName, steps, - initialStep + initialStep, } = rp.workflow; 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) { throw new Error( - `apiLoginBaseUrl is missing for workflow in client ${rp.clientId}.`); + `apiLoginBaseUrl is missing for workflow in client ${rp.clientId}.`, + ); } 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) { throw new Error( - `apiClientSecret is missing for workflow in client ${rp.clientId}.`); + `apiClientSecret is missing for workflow in client ${rp.clientId}.`, + ); } 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) { throw new Error( - `verifierDid is missing for workflow in client ${rp.clientId}.`); + `verifierDid is missing for workflow in client ${rp.clientId}.`, + ); } 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) { throw new Error( - `steps is missing for workflow in client ${rp.clientId}.`); + `steps is missing for workflow in client ${rp.clientId}.`, + ); } 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) { 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(', ')}.` + `${WorkFlowTypes.map(v => `'${v}'`).join(', ')}.`, ); } }; @@ -343,7 +364,7 @@ bedrock.events.on('bedrock.init', async () => { const defaultBrand = opencred.defaultBrand ?? { cta: '#006847', primary: '#008f5a', - header: '#004225' + header: '#004225', }; const validateDidWeb = () => { @@ -351,7 +372,7 @@ bedrock.events.on('bedrock.init', async () => { mainEnabled: opencred.didWeb?.mainEnabled, linkageEnabled: opencred.didWeb?.linkageEnabled, mainDocument: JSON.parse(opencred.didWeb?.mainDocument ?? '{}'), - linkageDocument: JSON.parse(opencred.didWeb?.linkageDocument ?? '{}') + linkageDocument: JSON.parse(opencred.didWeb?.linkageDocument ?? '{}'), }; }; opencred.didWeb = validateDidWeb(); @@ -367,12 +388,9 @@ bedrock.events.on('bedrock.init', async () => { 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.', ); } }); @@ -390,11 +408,11 @@ bedrock.events.on('bedrock.init', async () => { validateWorkflow(app); const brand = { ...defaultBrand, - ...(app.brand ? app.brand : {}) + ...(app.brand ? app.brand : {}), }; return { ...app, - brand + brand, }; }); @@ -410,8 +428,9 @@ bedrock.events.on('bedrock.init', async () => { } for(const issuer of scope.trustedCredentialIssuers) { if(typeof issuer !== 'string') { - throw new Error('Each issuer in trustedCredentialIssuers ' + - 'must be a string'); + throw new Error( + 'Each issuer in trustedCredentialIssuers ' + 'must be a string', + ); } } }; @@ -421,9 +440,10 @@ bedrock.events.on('bedrock.init', async () => { 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,8 +452,7 @@ 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(); @@ -446,25 +465,29 @@ bedrock.events.on('bedrock.init', async () => { 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.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(', ')); + throw new Error( + 'The config value of "reCaptcha.version" must be ' + + 'one of the following values: ' + + availableReCaptchaVersions.map(v => `"${v}"`).join(', '), + ); } } }; @@ -479,8 +502,7 @@ bedrock.events.on('bedrock.init', async () => { 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 @@ -505,33 +527,46 @@ bedrock.events.on('bedrock.init', async () => { 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(', ')); + 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(', ')); + 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) .sort() - .reduce((unique, currentId, currentIndex, ids) => - unique && currentId !== ids[currentIndex - 1], true); + .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) .sort() - .reduce((unique, currentPath, currentIndex, paths) => - unique && currentPath !== paths[currentIndex - 1], true); + .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".'); + throw new Error( + 'Each object in "audit.fields" must have ' + 'a unique "path".', + ); } }; validateAuditFields(); 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: diff --git a/lib/auth.js b/lib/auth.js index da93f9d..8f287f1 100644 --- a/lib/auth.js +++ b/lib/auth.js @@ -5,12 +5,12 @@ * SPDX-License-Identifier: BSD-3-Clause */ -import {BaseWorkflowService} from './workflows/base.js'; 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 +18,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 +27,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 +45,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 +95,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 +107,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 +125,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 +149,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..1769a39 100644 --- a/lib/http.js +++ b/lib/http.js @@ -15,24 +15,25 @@ 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 {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 {NativeWorkflowService} from './workflows/native-workflow.js'; import {newExchangeContext} from './workflows/common.js'; + import ResolveClientMiddleware from './resolveClient.js'; import {VCApiWorkflowService} from './workflows/vc-api-workflow.js'; @@ -48,7 +49,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 +67,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 +86,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 +104,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 +129,10 @@ bedrock.events.on('bedrock-express.configure.routes', app => { customTranslateScript, audit, reCaptcha: { - pages, version, siteKey - } + pages, + version, + siteKey, + }, }); }); @@ -146,6 +152,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 +294,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..bbbdf7b 100644 --- a/lib/workflows/base.js +++ b/lib/workflows/base.js @@ -8,25 +8,19 @@ 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) - ); + 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) + this.createExchange.bind(this), ); app.get( '/workflows/:workflowId/exchanges/:exchangeId', - this.getStatus.bind(this) + this.getStatus.bind(this), ); app.post( '/workflows/:workflowId/exchanges/:exchangeId/reset', - this.resetExchange.bind(this) + this.resetExchange.bind(this), ); } } @@ -37,7 +31,8 @@ export class BaseWorkflowService { const {rp, accessToken, oidc} = trustedVariables; throw new Error( 'Not implemented: createWorkflowSpecificExchange must be implemented ' + - 'in a workflow implementation.'); + 'in a workflow implementation.', + ); } async resetExchange(req, res) { @@ -52,12 +47,12 @@ export class BaseWorkflowService { ...exchange.variables, results: {}, authorizationRequest: null, - } + }, }; await database.collections.Exchanges.replaceOne( {id: exchange.id}, updatedExchange, - {upsert: false} + {upsert: false}, ); res.send(this.formatExchange(updatedExchange)); } @@ -65,15 +60,15 @@ export class BaseWorkflowService { async initExchange(trustedVariables, untrustedVariables) { 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) { variables = this.parseUntrustedVariables( rp.workflow.untrustedVariableAllowList, - untrustedVariables + untrustedVariables, ); } @@ -89,10 +84,11 @@ export class BaseWorkflowService { 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,18 +96,18 @@ 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) { try { untrustedVariables = JSON.parse( - base64url.decode(req.query?.variables ?? req.body?.variables) + base64url.decode(req.query?.variables ?? req.body?.variables), ); } catch(e) { res.status(400).send({ - message: 'Invalid variables supplied while creating exchange.' + message: 'Invalid variables supplied while creating exchange.', }); return; } @@ -119,7 +115,7 @@ export class BaseWorkflowService { try { const exchange = await this.createWorkflowSpecificExchange( {rp: req.rp, accessToken, oidc}, - untrustedVariables + untrustedVariables, ); if(exchange) { req.exchange = exchange; @@ -141,7 +137,7 @@ export class BaseWorkflowService { req.exchange = await this.getExchange({ rp, id: req.params.exchangeId, - allowExpired: true + allowExpired: true, }); } next(); @@ -155,7 +151,7 @@ export class BaseWorkflowService { const exchange = await this.getExchange({ rp: req.rp, id: exchangeId, - accessToken + accessToken, }); if(exchange) { req.exchange = this.formatExchange(exchange); @@ -164,12 +160,15 @@ export class BaseWorkflowService { } 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}}); + const exchange = await database.collections.Exchanges.findOne( + { + ...(id ? {id} : {}), + ...(accessToken ? {accessToken} : {}), + }, + {projection: {_id: 0}}, + ); if(!exchange || !rp) { return null; } @@ -197,7 +196,7 @@ export class BaseWorkflowService { 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}; diff --git a/lib/workflows/dc-api-workflow.js b/lib/workflows/dc-api-workflow.js new file mode 100644 index 0000000..9432beb --- /dev/null +++ b/lib/workflows/dc-api-workflow.js @@ -0,0 +1,303 @@ +/*! + * Copyright 2023 - 2024 California Department of Motor Vehicles + * Copyright 2025 Spruce Systems, Inc. + * + * SPDX-License-Identifier: BSD-3-Clause + */ + +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 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; + + 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 submissionEndpoint = rp.workflow.submissionEndpoint || ''; + const referenceEndpoint = rp.workflow.referenceEndpoint || ''; + + const dcApi = await DcApi.new( + sk.privateKeyPem, + rp?.workflow?.baseUrl, + submissionEndpoint, + referenceEndpoint, + encoder.encode(config.opencred.caStore[0]), + this.createOID4VPSessionStore(), + this.createDCAPISessionStore(), + ); + + return dcApi; + } + + async createWorkflowSpecificExchange(trustedVariables, untrustedVariables) { + if(trustedVariables.rp?.workflow?.type !== WORKFLOW_TYPE) { + return; + } + + const 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.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'}); + return; + } + + const requests = await this.dcApi.initiate_request( + sessionId, + sessionSecret, + JSON.parse(rp.workflow.dcApiRequest), + req.headers['user-agent'], + ); + + await database.collections.Exchanges.updateOne( + {id: exchange.id}, + { + $set: {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.session_creation_response.id, + exchange.variables.dcApiSession.session_creation_response.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: 'default', + 'oidc.code': oidcCode, + variables: { + ...exchange.variables, + results: { + default: results, + }, + }, + }, + }, + ); + + // 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'); + const callbackSuccess = await sendCallback( + rp.workflow, + updatedExchange, + 'default', + ); + if(!callbackSuccess) { + logger.warn('Failed to send callback to relying party'); + } + } + + // Return both results and updated exchange with OIDC code + res.send({ + ...results, + exchange: { + id: updatedExchange.id, + oidc: updatedExchange.oidc, + state: updatedExchange.state, + }, + }); + } catch(error) { + logger.error(error.message, {error}); + res.sendStatus(500); + } + } + + createOID4VPSessionStore() { + return new JsOid4VpSessionStore({ + async initiate(session) { + try { + await database.collections.Exchanges.updateOne( + { + 'variables.dcApiSession.session_creation_response.id': session.id, + }, + {$set: {'variables.oid4vpSession': session}}, + ); + } 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, + }); + }, + }; + } +} diff --git a/package.json b/package.json index 0e88b7b..009032e 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/opencred-dc-api": "^0.2.0" }, "devDependencies": { "@bedrock/test": "^8.2.0", diff --git a/web/components/DCApiView.vue b/web/components/DCApiView.vue new file mode 100644 index 0000000..e191984 --- /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..d34623f 100644 --- a/web/components/OID4VPView.vue +++ b/web/components/OID4VPView.vue @@ -6,262 +6,268 @@ SPDX-License-Identifier: BSD-3-Clause -->