From f31d5ef216a045cebce8219efba883bbdc441bbc Mon Sep 17 00:00:00 2001 From: Carsten Hagemann Date: Tue, 11 Nov 2025 11:02:09 +1000 Subject: [PATCH] Add web demo app for DPoP scenario --- README.md | 3 +- examples/README.md | 18 ++++ examples/dpop/README.md | 14 +++ examples/dpop/app.js | 208 +++++++++++++++++++++++++++++++++++++ examples/dpop/cert.pem | 21 ++++ examples/dpop/config.js | 1 + examples/dpop/key.pem | 27 +++++ examples/dpop/package.json | 22 ++++ 8 files changed, 313 insertions(+), 1 deletion(-) create mode 100644 examples/README.md create mode 100644 examples/dpop/README.md create mode 100644 examples/dpop/app.js create mode 100644 examples/dpop/cert.pem create mode 100644 examples/dpop/config.js create mode 100644 examples/dpop/key.pem create mode 100644 examples/dpop/package.json diff --git a/README.md b/README.md index e344cc2..d32e08d 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,8 @@ Releases of all packages are available here: [Releases](https://github.com/ibm-s The following components are currently offered in the package. | Component | Description | | ----------- | ----------- | -| [Privacy](sdk/privacy) | Fast, opinionated, simple privacy component that leverages the data privacy & consent engine on IBM Security Verify. | +| [Privacy](sdk/privacy) | Fast, opinionated, simple privacy component that leverages the data privacy & consent engine on IBM Verify. | +| [Examples](examples/) | Collection of **demo applications** that illustrate common server-side scenarios supporting mobile identity, authentication, and digital credential use cases. | ### Installation diff --git a/examples/README.md b/examples/README.md new file mode 100644 index 0000000..39ab5fe --- /dev/null +++ b/examples/README.md @@ -0,0 +1,18 @@ +# Mobile Scenario Demo Apps + +This repository contains a collection of **JavaScript demo applications**`** that illustrate common server-side scenarios supporting mobile identity, authentication, and digital credential use cases. + +Each demo app is designed to showcase specific integration patterns and protocols that can be used with mobile SDKs or clients implementing Digital Credentials (DC), OAuth 2.0, OpenID Connect, and related standards. + + +## Demo Apps + +| Name | Description | +|-----------|-------------| +| **dpop** | Demonstrates how to implement **Demonstration of Proof-of-Possession (DPoP)** for securing OAuth 2.0 access tokens. Shows how to generate and verify DPoP proofs on the server side. [Read the documentation →](./dpop/README.md) | + +## Prerequisites + +- [Node.js](https://nodejs.org/) (version 18 or higher recommended) +- npm or yarn +- Basic understanding of OAuth 2.0, OpenID Connect, and digital credential flows diff --git a/examples/dpop/README.md b/examples/dpop/README.md new file mode 100644 index 0000000..a3f6769 --- /dev/null +++ b/examples/dpop/README.md @@ -0,0 +1,14 @@ +This folder contains a custom web application used to demonstrate configuring DPoP flows with IBM Verify. + +# 🚀 Getting Started + +Clone this repository and install dependencies for a specific demo: + +1. `git clone https://github.com/IBM-Verify/verify-mobile-js.git` +1. `cd verify-mobile-js/dpop` +1. Configure the relevant parameter in `app.js` +1. `npm install` +1. `node app.js` + + +They are supporting assets for articles published on [IBM Verify Docs](https://docs.verify.ibm.com/verify/docs/best-practices-demonstration-proof-of-possession) or on IBM Verify community blogs. diff --git a/examples/dpop/app.js b/examples/dpop/app.js new file mode 100644 index 0000000..d22bfde --- /dev/null +++ b/examples/dpop/app.js @@ -0,0 +1,208 @@ +import express from 'express' +import jose from 'node-jose' +import crypto from 'crypto' +import https from 'https' +import fs from 'fs' +import { time } from 'console'; + +const app = express(); + +const key = fs.readFileSync('./key.pem'); +const cert = fs.readFileSync('./cert.pem'); + +// Change these parameters according to your IBM Security Verify tenant +const clientId = "" +const clientSecret = "" +const authorizationHeader = "Basic " + Buffer.from(`${clientId}:${clientSecret}`).toString('base64') +const tenant = "" // without protocol + +const port = 8080 +const introspectEndpoint = `/oauth2/introspect` + +let dpopProof = true +let computedFingerprint = '' +let tokenCache = [] + +app.get("/", (request, response) => { + response.send("Web app to demonstrate IBM Verify Identity high assurance flows") +}) + +app.get("/validate-token", async (request, response) => { + + computedFingerprint = '' + dpopProof = true + + console.log("-------------------------------------------------------------------------------------------") + const accessToken = request.headers.authorization.split(" ")[1] + const dpopHeader = request.headers["dpop"] + // console.log("Access token: " + accessToken) + // console.log("DPoP header: " + request.headers["dpop"]) + + await validateDpopHeader(request, dpopHeader, accessToken) + + if (dpopProof) { + const timeInSec = new Date().getTime() / 1000 + const cachedTokenExp = tokenCache[accessToken] + + if ((cachedTokenExp != undefined) && (timeInSec < cachedTokenExp)) { + console.log("Token found in cache: %s.", accessToken) + console.log("Valid until: %s", new Date(cachedTokenExp * 1000)) + console.log("Current time: %s", new Date(timeInSec * 1000)) + } else { + const responseText = await doTokenInspectionRequest(accessToken) + + const introspectionResponse = JSON.parse(responseText) + dpopProof = dpopProof && (introspectionResponse["cnf"]["jkt"] !== undefined) + dpopProof = dpopProof && (introspectionResponse["cnf"]["jkt"] === computedFingerprint) + console.log("JWK fingerprint match: " + (introspectionResponse["cnf"]["jkt"] === computedFingerprint)) + + if (dpopProof) { + console.log("Store token %s in cache. Expires at: %s", accessToken, new Date(introspectionResponse["exp"] * 1000)) + tokenCache[accessToken] = introspectionResponse["exp"] + } else { + console.log("Remove token %s from cache", accessToken) + tokenCache[accessToken] = undefined + } + } + } + + console.log("Authorization result: " + dpopProof.toString().toUpperCase()) + console.log("###########################################################################################") + if (dpopProof) { + response.sendStatus(204) + } else { + response.sendStatus(401) + } +}) + +async function doTokenInspectionRequest(accessToken) { + return new Promise((resolve, reject) => { + const options = { + hostname: tenant, + port: 443, + path: introspectEndpoint, + method: 'POST', + headers: { + 'Accept': 'application/json', + 'Content-Type': 'application/x-www-form-urlencoded', + 'Authorization': authorizationHeader + } + } + + const payload = `token=${accessToken}&token_type_hint=DPoP` + + let responseData = [] + // console.log("Request options: " + JSON.stringify(options)) + // console.log("Request payload: " + payload) + + const request = https.request(options, (response) => { + response.on('data', chunk => { + responseData.push(chunk) + }) + + response.on('end', function () { + const responseBody = Buffer.concat(responseData).toString() + console.log("Token introspection: " + responseBody) + resolve(responseBody) + }) + }) + + request.write(payload) + + request.on('error', error => { + console.log('> Error: ', error.message) + dpopProof = false + reject(error) + }) + + request.end(); + }) +} + +async function validateDpopHeader(request, dpopHeader, accessToken) { + + try { + dpopProof = dpopProof && (request.headers["dpop"].split(',').length == 1) + console.log("There is only one DPoP HTTP request header field: " + dpopProof) + + // The DPoP HTTP request header field value is a single and well-formed JWT. + // The JWT signature verifies with the public key contained in the jwk JOSE Header Parameter + let dpopHeaderUnpacked = await jose.JWS.createVerify().verify(dpopHeader, { allowEmbeddedKey: true }) + let jsonPayload = JSON.parse(dpopHeaderUnpacked.payload) + let htu = jsonPayload.htu + let htm = jsonPayload.htm + let ath = jsonPayload.ath + let jti = jsonPayload.jti + let iat = jsonPayload.iat + let exp = iat + 60 + + // All required claims per Section 4.2 are contained in the JWT. + dpopProof = dpopProof && (htu !== undefined) + dpopProof = dpopProof && (htm !== undefined) + dpopProof = dpopProof && (ath !== undefined) + dpopProof = dpopProof && (jti !== undefined) + dpopProof = dpopProof && (iat !== undefined) + console.log("All required claims per Section 4.2 are contained in the JWT: " + dpopProof) + + // The typ JOSE Header Parameter has the value dpop+jwt. + dpopProof = dpopProof && (dpopHeaderUnpacked.header.typ === "dpop+jwt") + console.log("The typ JOSE Header Parameter has the value dpop+jwt: " + (dpopHeaderUnpacked.header.typ === "dpop+jwt")) + + // The alg JOSE Header Parameter indicates a registered asymmetric digital signature algorithm. + dpopProof = dpopProof && (dpopHeaderUnpacked.header.alg === "RS256") + console.log("The alg JOSE Header Parameter indicates a registered asymmetric digital signature algorithm: " + (dpopHeaderUnpacked.header.alg === "RS256")) + + console.log("DPoP header payload: " + JSON.stringify(jsonPayload)) + console.log("Url: " + htu) + console.log("Method DPoP: " + htm) + console.log("Method request: " + request.method) + + // The htm claim matches the HTTP method of the current request. + dpopProof = dpopProof && (htm === request.method) + console.log("The htm claim matches the HTTP method of the current request: " + (htm === request.method)) + + // The htu claim matches the HTTP URI value for the HTTP request in which the JWT was received + const fullUrl = request.protocol + '://' + request.get('host') + request.originalUrl + dpopProof = dpopProof && (htu === fullUrl) + console.log("HTU: " + htu) + console.log("URL: " + fullUrl) + console.log("The htu claim matches the HTTP URI value for the HTTP request in which the JWT was received: " + (htu === fullUrl)) + + // The creation time of the JWT ... is within an acceptable window. + const timeInSec = new Date().getTime() / 1000 + dpopProof = dpopProof && (iat < timeInSec + 1) && (exp > timeInSec) + console.log("iat " + new Date(iat * 1000)) + console.log("cts " + new Date((timeInSec + 1) * 1000)) + console.log("exp " + new Date(exp * 1000)) + console.log("The creation time of the JWT ... is within an acceptable window of 60 seconds: " + ((iat < (timeInSec + 1)) && (exp > timeInSec))) + + let digest = crypto.createHash('sha256').update(accessToken).digest() + let atHash = jose.util.base64url.encode(digest); + console.log("ath (calculated): " + atHash) + console.log("ath (from DPoP header): " + ath) + + dpopProof = dpopProof && (atHash === ath) + console.log("DPoP header is associated with the access token: " + (atHash === ath)) + + let thumbprint = await dpopHeaderUnpacked.key.thumbprint('SHA-256'); + computedFingerprint = jose.util.base64url.encode(thumbprint); + console.log("JWK fingerprint: " + computedFingerprint) + + } catch (error) { + dpopProof = false + console.log(error) + } +} + +app.get("/status", (request, response) => { + const status = { + "Status": "Running" + } + + response.send(status) +}) + +const server = https.createServer({key: key, cert: cert }, app); +server.listen(8080, () => { + console.log(`SERVER STARTED ON localhost:${port}`); +}) diff --git a/examples/dpop/cert.pem b/examples/dpop/cert.pem new file mode 100644 index 0000000..1014198 --- /dev/null +++ b/examples/dpop/cert.pem @@ -0,0 +1,21 @@ +-----BEGIN CERTIFICATE----- +MIIDXjCCAkYCCQC9EfBtFzDtZjANBgkqhkiG9w0BAQsFADBxMQswCQYDVQQGEwJB +VTEMMAoGA1UECAwDUUxEMRMwEQYDVQQHDApHb2xkIENvYXN0MRUwEwYDVQQKDAxJ +Qk0gU2VjdXJpdHkxFDASBgNVBAsMC0RldmVsb3BtZW50MRIwEAYDVQQDDAlsb2Nh +bGhvc3QwHhcNMjMxMDMxMTAwMDE5WhcNMjYwNzI3MTAwMDE5WjBxMQswCQYDVQQG +EwJBVTEMMAoGA1UECAwDUUxEMRMwEQYDVQQHDApHb2xkIENvYXN0MRUwEwYDVQQK +DAxJQk0gU2VjdXJpdHkxFDASBgNVBAsMC0RldmVsb3BtZW50MRIwEAYDVQQDDAls +b2NhbGhvc3QwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQC17TRiDYiq +FiRDtXwY/V3xaPZR1h3gHSrkKNFLNaT21cem6MVwMODG9aWgPbxKufBZCJ0i+xax +iGv0VUZ6vayxXmRC5ByTzLZGhW9s6AyUfcx7+sOuFxx20IwjNheGTe8K0E/tK84u +IG2Yy2wvBIjSyw0u+lNjhVB0+p6SclM8s+yMPkyfu3l7H3x/vdpdx3/HjDmkTLu3 +1Et2cLTavVzMc6LPVv1HIOWhNvTQBlVCY6LqVIwU9D5L0warnDfqxTgBuKF/RECy +47H4lQP5O5Gin84cDmckO+Ji6BH2pgyEt1h//8TK5DXNoTZajArtdzjxYkxB/cA4 +YdJRDJniooGdAgMBAAEwDQYJKoZIhvcNAQELBQADggEBAAprcZTALXSW4wJ9DHeW +zvLu/CyJzJtOXsLh2DpkKyJ0S3C6OjGTd2WbKf7aIKTKDzafa7xPl6ptccbY1pVO +b2RWLPz2nPrDtHyau1GJKL+rQvOqKSIifNv+Qrh16Pp/F5hY3OdanujzztdqrDUr +TjDDmYoASGE2hCDB9MwEAV7vb+64JWVZ2Hsqjv2bKOGJv/MRTn0ub2UYl+YM1ZLS +ZeqVYMrjskpGQQG3U4GcI6dv3G93GDNRxiEnsfd0SHaDofn4MWi+I2I0JKt2G9w4 +tCE8kNTjOBdLToUPeAd/5d3k6xGRf3MflD2ADJX5th1MOD4U049AFecYZ16GX1AS +/JU= +-----END CERTIFICATE----- diff --git a/examples/dpop/config.js b/examples/dpop/config.js new file mode 100644 index 0000000..4a0b330 --- /dev/null +++ b/examples/dpop/config.js @@ -0,0 +1 @@ +const PORT = process.env.PORT || 8080; \ No newline at end of file diff --git a/examples/dpop/key.pem b/examples/dpop/key.pem new file mode 100644 index 0000000..e3e326d --- /dev/null +++ b/examples/dpop/key.pem @@ -0,0 +1,27 @@ +-----BEGIN RSA PRIVATE KEY----- +MIIEowIBAAKCAQEAte00Yg2IqhYkQ7V8GP1d8Wj2UdYd4B0q5CjRSzWk9tXHpujF +cDDgxvWloD28SrnwWQidIvsWsYhr9FVGer2ssV5kQuQck8y2RoVvbOgMlH3Me/rD +rhccdtCMIzYXhk3vCtBP7SvOLiBtmMtsLwSI0ssNLvpTY4VQdPqeknJTPLPsjD5M +n7t5ex98f73aXcd/x4w5pEy7t9RLdnC02r1czHOiz1b9RyDloTb00AZVQmOi6lSM +FPQ+S9MGq5w36sU4Abihf0RAsuOx+JUD+TuRop/OHA5nJDviYugR9qYMhLdYf//E +yuQ1zaE2WowK7Xc48WJMQf3AOGHSUQyZ4qKBnQIDAQABAoIBADpgwZxtR1t8+2oW +xJmoRAsBaXldQfz2nxrT1kVSE3t5ojV3IFQd+yMdFGbVKKS6AdwwHWxqMiAJ7Mpc +yt78GnxYE8g7NsheumbqzpSd6duEeqeWElC87c9aoH10EBxyybAopF0w31qB4WlU +bYSw6c7qhXJ7tWKMFwNSoKvRr9AcSdGGzQx/lOVPTqDxtEQdfX6mIApa1lLkAnHM +8qou/X1kMNsugv9ci6Vti+B7/JVK8HUtYtY1feIbAyqhhFXrSsDUPFwi95Eb03JL +OnWaD2n67Lh6axiOFcCB5LbIdK/gsDdDb/w9Ax4I4u3CLaz6oc5UJWDRUlge8FKT +rOYB2CkCgYEA3WYaoFqSFxDx35YBJShBgOve0Y7bsAaxhXJioYosUR3pkFy7ie2T +t916GXiyo6grU+Z28PvKl9Xri64atILg6m4rG5fmX8QbksW0xY/CTYDBSyzEPFQ4 +3/BFWSkmL3OXJTA+y5X3PG9tl/ZaQyiaeQoFQz5Rlsw57BxDpdASHKsCgYEA0lvd +IpOlYwlyx/p1ewYf1//7bXuWYmqTmgyI66pVyoCgWIYLDR8sx65x1JKQFWf5q0Lm +WJCsjviuUxEe4AzExDz3Pney8qGpEuDL+oZXiMm7XxSAS0EPnfz/zokfBVsSnTfN +J9ui2QxAm5uqeKE9d6gviSkucsjHBTqnBW9pStcCgYEAhhCfDPzrO+Z54J9QADUZ +PT0XRyRPqY7UDm/Og+1Mmq4XUeCle4cOjScjGat52RorannDCngeHMVgBcwexbH/ +ClY96k6YJON2ovDvXzaHPTE7Ww83oSSK8Cfphm0hf1hqbQ2C6PrdpI4A+iUmUUaU +C7liqG5jL4JpjK0s1YpifVMCgYA5kGXtJlYlydodG062wbBJHYWiKiW2/M8zYqa9 +Rrl/Vr+KOfQgPR217ui7cPf4w6Ew2nfKWJy/6xFZLeAzE4ts3/oQoBTaDJ8FyXpI +LicaCYo6tJN/BGjPpQIjdKaGgquPVkvP0my31ICBlJGLvSPi8KVBdYF+a676oVg/ +RoObtQKBgH2lXDo7mQkZLsJoe8ob4ssu9Kb8gaCiHNptwhydubcdF0rK47l+yMUM +ZIe4CMAW3Ley/ru3CcKgRry9FRRULv1D9kZ+CbPhp7cnWwl6/6NglaRg1rc+tXte +Zob6zpcaFDC/ngwhip00DbbtPqUHqJXSBId4HQ0VkOQT+ycqH4o9 +-----END RSA PRIVATE KEY----- diff --git a/examples/dpop/package.json b/examples/dpop/package.json new file mode 100644 index 0000000..2576db5 --- /dev/null +++ b/examples/dpop/package.json @@ -0,0 +1,22 @@ +{ + "name": "dpop-demo-app", + "version": "1.0.0", + "description": "Demo for DPoP token handling", + "main": "app.js", + "type": "module", + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1" + }, + "author": "", + "license": "ISC", + "dependencies": { + "body-parser": "^1.20.2", + "ejs": "^3.1.9", + "expres": "^0.0.5", + "express": "^4.18.2", + "https": "^1.0.0", + "node-jose": "^2.2.0", + "nodemon": "^3.0.1", + "openid-client": "^5.6.1" + } +}