From e31dc7eb9638b5cd3be665b7a68adc9ca7da7f6e Mon Sep 17 00:00:00 2001 From: ivo liondov Date: Mon, 22 Sep 2025 09:26:31 +0100 Subject: [PATCH 1/8] Remove tsndr dependency Support EC256 keys and drop tsndr dependency since there is no support for non printable characters in the secrets --- src/index.js | 380 +++++++++++++++++++++++++++++++-------------------- 1 file changed, 233 insertions(+), 147 deletions(-) diff --git a/src/index.js b/src/index.js index b1346b3..e5073f7 100644 --- a/src/index.js +++ b/src/index.js @@ -1,167 +1,253 @@ -// Cloudflare Approov Worker -// -// This worker acts as a reverse proxy in front of an API service. The proxy validates -// an Approov token. If the token is valid, then the API call is authorized, and the worker -// rewrites the API call, submits it to the API service, and returns the response. - -import jwt from '@tsndr/cloudflare-worker-jwt' - -// Establish context from environmental settings. - -const establishContext = (env) => { - const approovSecretBase64 = env.APPROOV_SECRET_BASE64 - const approovSecret = approovSecretBase64 ? atob(approovSecretBase64) : null - const approovTokenHeaderName = - env.APPROOV_TOKEN_HEADER_NAME || 'Approov-Token' - const approovBindingHeaderName = - env.APPROOV_BINDING_HEADER_NAME || 'Authorization' - const approovBindingClaimName = env.APPROOV_BINDING_CLAIM_NAME || 'pay' - const approovBindingVerification = - env.APPROOV_VERIFICATION_STRATEGY === 'token-binding' || false - - const apiHost = env.API_DOMAIN - - const ctx = { - approovSecret, - approovTokenHeaderName, - approovBindingHeaderName, - approovBindingClaimName, - approovBindingVerification, - apiHost, - isValid: !!(approovSecret && apiHost), - } - - return ctx +// node_modules/@tsndr/cloudflare-worker-jwt/index.js +function bytesToByteString(bytes) { + let byteStr = ""; + for (let i = 0; i < bytes.byteLength; i++) { + byteStr += String.fromCharCode(bytes[i]); + } + return byteStr; + } + function byteStringToBytes(byteStr) { + let bytes = new Uint8Array(byteStr.length); + for (let i = 0; i < byteStr.length; i++) { + bytes[i] = byteStr.charCodeAt(i); + } + return bytes; + } + function arrayBufferToBase64String(arrayBuffer) { + return btoa(bytesToByteString(new Uint8Array(arrayBuffer))); + } + function base64StringToUint8Array(b64str) { + return byteStringToBytes(atob(b64str)); + } + function textToUint8Array(str) { + return byteStringToBytes(str); + } + function arrayBufferToBase64Url(arrayBuffer) { + return arrayBufferToBase64String(arrayBuffer).replace(/=/g, "").replace(/\+/g, "-").replace(/\//g, "_"); + } + function base64UrlToUint8Array(b64url) { + return base64StringToUint8Array(b64url.replace(/-/g, "+").replace(/_/g, "/").replace(/\s/g, "")); + } + function textToBase64Url(str) { + const encoder = new TextEncoder(); + const charCodes = encoder.encode(str); + const binaryStr = String.fromCharCode(...charCodes); + return btoa(binaryStr).replace(/=/g, "").replace(/\+/g, "-").replace(/\//g, "_"); + } + function pemToBinary(pem) { + return base64StringToUint8Array(pem.replace(/-+(BEGIN|END).*/g, "").replace(/\s/g, "")); + } + async function importTextSecret(key, algorithm, keyUsages) { + return await crypto.subtle.importKey("raw", textToUint8Array(key), algorithm, true, keyUsages); + } + async function importJwk(key, algorithm, keyUsages) { + return await crypto.subtle.importKey("jwk", key, algorithm, true, keyUsages); + } + async function importPublicKey(key, algorithm, keyUsages) { + return await crypto.subtle.importKey("spki", pemToBinary(key), algorithm, true, keyUsages); + } + async function importPrivateKey(key, algorithm, keyUsages) { + return await crypto.subtle.importKey("pkcs8", pemToBinary(key), algorithm, true, keyUsages); + } + async function importKey(key, algorithm, keyUsages) { + if (typeof key === "object") + return importJwk(key, algorithm, keyUsages); + if (typeof key !== "string") + throw new Error("Unsupported key type!"); + if (key.includes("PUBLIC")) + return importPublicKey(key, algorithm, keyUsages); + if (key.includes("PRIVATE")) + return importPrivateKey(key, algorithm, keyUsages); + return importTextSecret(key, algorithm, keyUsages); + } + function decodePayload(raw) { + try { + const bytes = Array.from(atob(raw), (char) => char.charCodeAt(0)); + const decodedString = new TextDecoder("utf-8").decode(new Uint8Array(bytes)); + return JSON.parse(decodedString); + } catch { + return; + } + } +if (typeof crypto === "undefined" || !crypto.subtle) + throw new Error("SubtleCrypto not supported!"); +var algorithms = { + ES256: { name: "ECDSA", namedCurve: "P-256", hash: { name: "SHA-256" } }, + HS256: { name: "HMAC", hash: { name: "SHA-256" } } +}; +async function sign(payload, secret, options = "HS256") { + if (typeof options === "string") + options = { algorithm: options }; + options = { algorithm: "HS256", header: { typ: "JWT", ...options.header ?? {} }, ...options }; + if (!payload || typeof payload !== "object") + throw new Error("payload must be an object"); + if (!secret || (typeof secret !== "string" && typeof secret !== "object")) + throw new Error("secret must be a string, a JWK object or a CryptoKey object"); + if (typeof options.algorithm !== "string") + throw new Error("options.algorithm must be a string"); + if (!(options.algorithm === "HS256" || options.algorithm === "ES256")) + throw new Error("Only HS256 and ES256 algorithms are supported"); + const algorithm = algorithms[options.algorithm]; + if (!algorithm) + throw new Error("algorithm not found"); + if (!payload.iat) + payload.iat = Math.floor(Date.now() / 1e3); + const partialToken = `${textToBase64Url(JSON.stringify({ ...options.header, alg: options.algorithm }))}.${textToBase64Url(JSON.stringify(payload))}`; + const key = secret instanceof CryptoKey ? secret : await importKey(secret, algorithm, ["sign"]); + const signature = await crypto.subtle.sign(algorithm, key, textToUint8Array(partialToken)); + return `${partialToken}.${arrayBufferToBase64Url(signature)}`; } - -// Extract Approov token string from request headers. - -const extractToken = (ctx, request) => { - const token = request.headers.get(ctx.approovTokenHeaderName) - - return token -} - -// Validate Approov token is properly signed and not expired. - -const validateToken = async (ctx, token) => { - if (!ctx || !token) return false - - const options = { algorithm: 'HS256', throwError: false } - - const isValid = await jwt.verify(token, ctx.approovSecret, options) - - return isValid -} - -// Extract Approov binding string from request headers. - -const extractBinding = (ctx, request) => { - const binding = request.headers.get(ctx.approovBindingHeaderName) - - return binding -} - -// Validate token payload has expected binding hash. - -const validateBinding = async (ctx, token, binding) => { - if (!ctx || !token || !binding) return false - - // hash binding string to array buffer - - const encoder = new TextEncoder() - const data = encoder.encode(binding) - const buffer = await crypto.subtle.digest('SHA-256', data) - - // convert array buffer to base64 string - - var binary = '' - const bytes = new Uint8Array(buffer) - const len = bytes.byteLength - for (var i = 0; i < len; i++) { - binary += String.fromCharCode(bytes[i]) +async function verify(token, secret, options = "HS256") { + if (typeof options === "string") + options = { algorithm: options }; + options = { algorithm: "HS256", clockTolerance: 0, throwError: false, ...options }; + if (typeof token !== "string") + throw new Error("token must be a string"); + if (typeof secret !== "string" && typeof secret !== "object") + throw new Error("secret must be a string, a JWK object or a CryptoKey object"); + if (typeof options.algorithm !== "string") + throw new Error("options.algorithm must be a string"); + if (!(options.algorithm === "HS256" || options.algorithm === "ES256")) + throw new Error("Only HS256 and ES256 algorithms are supported"); + const tokenParts = token.split("."); + if (tokenParts.length !== 3) + throw new Error("token must consist of 3 parts"); + const algorithm = algorithms[options.algorithm]; + if (!algorithm) + throw new Error("algorithm not found"); + const decodedToken = decode(token); + try { + if (decodedToken.header?.alg !== options.algorithm) + throw new Error("INVALID_SIGNATURE"); + if (decodedToken.payload) { + const now = Math.floor(Date.now() / 1e3); + if (decodedToken.payload.nbf && decodedToken.payload.nbf > now && decodedToken.payload.nbf - now > (options.clockTolerance ?? 0)) + throw new Error("NOT_YET_VALID"); + if (decodedToken.payload.exp && decodedToken.payload.exp <= now && now - decodedToken.payload.exp > (options.clockTolerance ?? 0)) + throw new Error("EXPIRED"); + } + const key = secret instanceof CryptoKey ? secret : await importKey(secret, algorithm, ["verify"]); + if (!await crypto.subtle.verify(algorithm, key, base64UrlToUint8Array(tokenParts[2]), textToUint8Array(`${tokenParts[0]}.${tokenParts[1]}`))) + throw new Error("INVALID_SIGNATURE"); + return decodedToken; + } catch (err) { + if (options.throwError) + throw err; + return; } - const hash = btoa(binary) - - // assume token already validated - - // extract pay claim - - const { payload } = jwt.decode(token) - const claim = payload ? payload[ctx.approovBindingClaimName] : null - if (!claim) return false - - // compare claim to hash - - return claim === hash } - -// Rewrite unprotected API request. - -const rewriteApiRequest = (ctx, request) => { - const url = new URL(request.url) - url.host = ctx.apiHost - - // substitute api host url and delete approov token - const apiRequest = new Request(url.toString(), request) - apiRequest.headers.delete(ctx.approovHeaderName) - - return apiRequest +function decode(token) { + return { + header: decodePayload(token.split(".")[0].replace(/-/g, "+").replace(/_/g, "/")), + payload: decodePayload(token.split(".")[1].replace(/-/g, "+").replace(/_/g, "/")) + }; } - -// Handle request. - -const handleRequest = async (request, env) => { - // establish context - - const ctx = establishContext(env) +var index_default = { + sign, + verify, + decode +}; + +// src/index.js +var establishContext = (env) => { + const approovSecretRaw = env.APPROOV_SECRET_BASE64.trim(); + let approovSecret; + if (approovSecretRaw.includes("PUBLIC KEY")) { + approovSecret = approovSecretRaw.replace(/-----BEGIN PUBLIC KEY-----/g, "").replace(/-----END PUBLIC KEY-----/g, "").replace(/\n/g, "").trim(); + } else { + approovSecret = atob(approovSecretRaw); + } + const ctx = { + approovSecret, + approovTokenHeaderName: env.APPROOV_TOKEN_HEADER_NAME || "Approov-Token", + approovBindingHeaderName: env.APPROOV_BINDING_HEADER_NAME || "Authorization", + approovBindingClaimName: "pay", + approovBindingVerification: env.APPROOV_BINDING_VERIFICATION || false, + isValid: !!approovSecret + }; + return ctx; +}; +var extractToken = (ctx, request) => { + return request.headers.get(ctx.approovTokenHeaderName); +}; +var validateToken = async (ctx, token) => { + if (!ctx || !token) return false; + const { header } = index_default.decode(token); + if (!header || !header.alg) return false; + const allowedAlgorithms = ["ES256", "HS256"]; + const algorithm = header.alg; + if (!allowedAlgorithms.includes(algorithm)) { + console.error(`AUTH FAILURE: Unsupported JWT algorithm: ${algorithm}`); + return false; + } + const options = { algorithm, throwError: false }; + return await index_default.verify(token, ctx.approovSecret, options); +}; +var extractBinding = (ctx, request) => { + return request.headers.get(ctx.approovBindingHeaderName); +}; +var validateBinding = async (ctx, token, binding) => { + if (!ctx || !token || !binding) return false; + const encoder = new TextEncoder(); + const data = encoder.encode(binding); + const buffer = await crypto.subtle.digest("SHA-256", data); + let binary = ""; + const bytes = new Uint8Array(buffer); + const len = bytes.byteLength; + for (let i = 0; i < len; i++) { + binary += String.fromCharCode(bytes[i]); + } + const hash = btoa(binary); + const { payload } = index_default.decode(token); + const claim = payload ? payload[ctx.approovBindingClaimName] : null; + if (!claim) return false; + return claim === hash; +}; +var handleRequest = async (request, env) => { + const ctx = establishContext(env); if (!ctx.isValid) { - console.error( - `CONTEXT ERROR: unable to establish context; check environmental values and secrets` - ) - return new Response('internal server error', { status: 500 }) + console.error(`CONTEXT ERROR: Unable to establish context; check environmental values and secrets`); + return new Response("internal server error", { status: 500 }); } - // validate approov token - - const approovToken = extractToken(ctx, request) + const approovToken = extractToken(ctx, request); if (!approovToken) { - console.error(`AUTH FAILURE: approov token not found`) - return new Response('unauthorized', { status: 401 }) + console.error(`AUTH FAILURE: Approov token not found`); + return new Response("unauthorized", { status: 401 }); } - let isAuthorized = await validateToken(ctx, approovToken) + let isAuthorized = await validateToken(ctx, approovToken); if (!isAuthorized) { - console.error(`AUTH FAILURE: approov token expired or not properly signed`) - return new Response('unauthorized', { status: 401 }) + console.error(`AUTH FAILURE: Approov token expired or not properly signed`); + return new Response("unauthorized", { status: 401 }); } - // if binding strategy, validate approov binding - if (ctx.approovBindingVerification) { - const approovBinding = extractBinding(ctx, request) - - isAuthorized = await validateBinding(ctx, approovToken, approovBinding) + const approovBinding = extractBinding(ctx, request); + isAuthorized = await validateBinding(ctx, approovToken, approovBinding); if (!isAuthorized) { - console.error(`AUTH FAILURE: approov token binding missing or invalid`) - return new Response('unauthorized', { status: 401 }) + console.error(`AUTH FAILURE: Approov token binding missing or invalid`); + return new Response("unauthorized", { status: 401 }); } } - // rewrite and submit authorized request - - const apiRequest = rewriteApiRequest(ctx, request) - const response = await fetch(apiRequest) - - // return response - - return response -} + // Forward the request to the original URL (preserving method, headers, and body) + // Remove the Approov token header before forwarding + const url = new URL(request.url); + // Optionally, you may want to delete the Approov token header (as in original.js) + const forwardRequest = new Request(url.toString(), request); + //forwardRequest.headers.delete(ctx.approovTokenHeaderName); + + // Forward the request and return the response + return fetch(forwardRequest); +}; +var index_default2 = { + async fetch(request, env) { + return await handleRequest(request, env); + } +}; +export { + index_default2 as default +}; -// Export the fetch handler to be used by the cloudflare worker. -export default { - async fetch(request, env) { - return await handleRequest(request, env) - }, -} From 096d2511cfc316a4b454ee3f34b0cd79908bf5e8 Mon Sep 17 00:00:00 2001 From: ivo liondov Date: Mon, 22 Sep 2025 15:47:35 +0100 Subject: [PATCH 2/8] Update index.js Log specific failure reason to allow faster troubleshooting --- src/index.js | 24 ++++++++++++++---------- 1 file changed, 14 insertions(+), 10 deletions(-) diff --git a/src/index.js b/src/index.js index e5073f7..93999d7 100644 --- a/src/index.js +++ b/src/index.js @@ -171,17 +171,22 @@ var extractToken = (ctx, request) => { return request.headers.get(ctx.approovTokenHeaderName); }; var validateToken = async (ctx, token) => { - if (!ctx || !token) return false; + if (!ctx || !token) return { valid: false, reason: "No context or token" }; const { header } = index_default.decode(token); - if (!header || !header.alg) return false; + if (!header || !header.alg) return { valid: false, reason: "No header or alg" }; const allowedAlgorithms = ["ES256", "HS256"]; const algorithm = header.alg; if (!allowedAlgorithms.includes(algorithm)) { - console.error(`AUTH FAILURE: Unsupported JWT algorithm: ${algorithm}`); - return false; + return { valid: false, reason: `Unsupported JWT algorithm: ${algorithm}` }; + } + const options = { algorithm, throwError: true }; + try { + const result = await index_default.verify(token, ctx.approovSecret, options); + if (!result) return { valid: false, reason: "Unknown verification failure" }; + return { valid: true }; + } catch (err) { + return { valid: false, reason: err && err.message ? err.message : String(err) }; } - const options = { algorithm, throwError: false }; - return await index_default.verify(token, ctx.approovSecret, options); }; var extractBinding = (ctx, request) => { return request.headers.get(ctx.approovBindingHeaderName); @@ -216,9 +221,9 @@ var handleRequest = async (request, env) => { return new Response("unauthorized", { status: 401 }); } - let isAuthorized = await validateToken(ctx, approovToken); - if (!isAuthorized) { - console.error(`AUTH FAILURE: Approov token expired or not properly signed`); + const tokenResult = await validateToken(ctx, approovToken); + if (!tokenResult.valid) { + console.error(`AUTH FAILURE: ${tokenResult.reason}`); return new Response("unauthorized", { status: 401 }); } @@ -250,4 +255,3 @@ export { index_default2 as default }; - From fce81152c269c5d8a203e98b7c2259fed1e14368 Mon Sep 17 00:00:00 2001 From: ivo liondov Date: Mon, 22 Sep 2025 18:08:54 +0100 Subject: [PATCH 3/8] Update index.js Log request headers for debugging --- src/index.js | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/index.js b/src/index.js index 93999d7..1cb3538 100644 --- a/src/index.js +++ b/src/index.js @@ -209,6 +209,11 @@ var validateBinding = async (ctx, token, binding) => { return claim === hash; }; var handleRequest = async (request, env) => { + // Log the request headers for debugging purposes + console.log('Request Headers:'); + for (const [key, value] of request.headers.entries()) { + console.log(`${key}: ${value}`); + } const ctx = establishContext(env); if (!ctx.isValid) { console.error(`CONTEXT ERROR: Unable to establish context; check environmental values and secrets`); From 95cda94f0c089bbe4b78371809f87a2532319dd2 Mon Sep 17 00:00:00 2001 From: ivo liondov Date: Tue, 23 Sep 2025 09:08:56 +0100 Subject: [PATCH 4/8] Update index.js Use base64URL secret instead of base64 only --- src/index.js | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/index.js b/src/index.js index 1cb3538..fb7cec7 100644 --- a/src/index.js +++ b/src/index.js @@ -155,7 +155,10 @@ var establishContext = (env) => { if (approovSecretRaw.includes("PUBLIC KEY")) { approovSecret = approovSecretRaw.replace(/-----BEGIN PUBLIC KEY-----/g, "").replace(/-----END PUBLIC KEY-----/g, "").replace(/\n/g, "").trim(); } else { - approovSecret = atob(approovSecretRaw); + // Convert base64url to base64 before atob + let b64 = approovSecretRaw.replace(/-/g, "+").replace(/_/g, "/"); + while (b64.length % 4) b64 += "="; + approovSecret = atob(b64); } const ctx = { approovSecret, From 30621d82b8c5dc5eef6644e1dce6934b8cf8f273 Mon Sep 17 00:00:00 2001 From: ivo liondov Date: Tue, 23 Sep 2025 10:26:56 +0100 Subject: [PATCH 5/8] Update index.js Accept base64url and base64 value as secret and convert conditionaly --- src/index.js | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/src/index.js b/src/index.js index fb7cec7..30d1dc1 100644 --- a/src/index.js +++ b/src/index.js @@ -155,10 +155,14 @@ var establishContext = (env) => { if (approovSecretRaw.includes("PUBLIC KEY")) { approovSecret = approovSecretRaw.replace(/-----BEGIN PUBLIC KEY-----/g, "").replace(/-----END PUBLIC KEY-----/g, "").replace(/\n/g, "").trim(); } else { - // Convert base64url to base64 before atob - let b64 = approovSecretRaw.replace(/-/g, "+").replace(/_/g, "/"); - while (b64.length % 4) b64 += "="; - approovSecret = atob(b64); + // Accept both base64url and standard base64 + let b64 = approovSecretRaw; + if (/[^A-Za-z0-9+/=]/.test(b64)) { + // If contains base64url chars, convert to base64 + b64 = b64.replace(/-/g, "+").replace(/_/g, "/"); + } + b64 = b64.padEnd(Math.ceil(b64.length / 4) * 4, "="); + approovSecret = atob(b64); } const ctx = { approovSecret, From 56320b1071eda34b1bea7f963d237018a7a8946a Mon Sep 17 00:00:00 2001 From: ivo liondov Date: Wed, 24 Sep 2025 14:50:38 +0100 Subject: [PATCH 6/8] Update index.js Add arc code message handling and different conditions for failure to allow client applications to handle different error modes --- src/index.js | 483 +++++++++++++++++++++++++++------------------------ 1 file changed, 255 insertions(+), 228 deletions(-) diff --git a/src/index.js b/src/index.js index 30d1dc1..cea3880 100644 --- a/src/index.js +++ b/src/index.js @@ -1,161 +1,161 @@ // node_modules/@tsndr/cloudflare-worker-jwt/index.js function bytesToByteString(bytes) { - let byteStr = ""; - for (let i = 0; i < bytes.byteLength; i++) { - byteStr += String.fromCharCode(bytes[i]); - } - return byteStr; - } - function byteStringToBytes(byteStr) { - let bytes = new Uint8Array(byteStr.length); - for (let i = 0; i < byteStr.length; i++) { - bytes[i] = byteStr.charCodeAt(i); - } - return bytes; - } - function arrayBufferToBase64String(arrayBuffer) { - return btoa(bytesToByteString(new Uint8Array(arrayBuffer))); - } - function base64StringToUint8Array(b64str) { - return byteStringToBytes(atob(b64str)); - } - function textToUint8Array(str) { - return byteStringToBytes(str); - } - function arrayBufferToBase64Url(arrayBuffer) { - return arrayBufferToBase64String(arrayBuffer).replace(/=/g, "").replace(/\+/g, "-").replace(/\//g, "_"); - } - function base64UrlToUint8Array(b64url) { - return base64StringToUint8Array(b64url.replace(/-/g, "+").replace(/_/g, "/").replace(/\s/g, "")); - } - function textToBase64Url(str) { - const encoder = new TextEncoder(); - const charCodes = encoder.encode(str); - const binaryStr = String.fromCharCode(...charCodes); - return btoa(binaryStr).replace(/=/g, "").replace(/\+/g, "-").replace(/\//g, "_"); - } - function pemToBinary(pem) { - return base64StringToUint8Array(pem.replace(/-+(BEGIN|END).*/g, "").replace(/\s/g, "")); - } - async function importTextSecret(key, algorithm, keyUsages) { - return await crypto.subtle.importKey("raw", textToUint8Array(key), algorithm, true, keyUsages); - } - async function importJwk(key, algorithm, keyUsages) { - return await crypto.subtle.importKey("jwk", key, algorithm, true, keyUsages); - } - async function importPublicKey(key, algorithm, keyUsages) { - return await crypto.subtle.importKey("spki", pemToBinary(key), algorithm, true, keyUsages); - } - async function importPrivateKey(key, algorithm, keyUsages) { - return await crypto.subtle.importKey("pkcs8", pemToBinary(key), algorithm, true, keyUsages); - } - async function importKey(key, algorithm, keyUsages) { - if (typeof key === "object") - return importJwk(key, algorithm, keyUsages); - if (typeof key !== "string") - throw new Error("Unsupported key type!"); - if (key.includes("PUBLIC")) - return importPublicKey(key, algorithm, keyUsages); - if (key.includes("PRIVATE")) - return importPrivateKey(key, algorithm, keyUsages); - return importTextSecret(key, algorithm, keyUsages); - } - function decodePayload(raw) { - try { - const bytes = Array.from(atob(raw), (char) => char.charCodeAt(0)); - const decodedString = new TextDecoder("utf-8").decode(new Uint8Array(bytes)); - return JSON.parse(decodedString); - } catch { - return; - } - } + let byteStr = ""; + for (let i = 0; i < bytes.byteLength; i++) { + byteStr += String.fromCharCode(bytes[i]); + } + return byteStr; +} +function byteStringToBytes(byteStr) { + let bytes = new Uint8Array(byteStr.length); + for (let i = 0; i < byteStr.length; i++) { + bytes[i] = byteStr.charCodeAt(i); + } + return bytes; +} +function arrayBufferToBase64String(arrayBuffer) { + return btoa(bytesToByteString(new Uint8Array(arrayBuffer))); +} +function base64StringToUint8Array(b64str) { + return byteStringToBytes(atob(b64str)); +} +function textToUint8Array(str) { + return byteStringToBytes(str); +} +function arrayBufferToBase64Url(arrayBuffer) { + return arrayBufferToBase64String(arrayBuffer).replace(/=/g, "").replace(/\+/g, "-").replace(/\//g, "_"); +} +function base64UrlToUint8Array(b64url) { + return base64StringToUint8Array(b64url.replace(/-/g, "+").replace(/_/g, "/").replace(/\s/g, "")); +} +function textToBase64Url(str) { + const encoder = new TextEncoder(); + const charCodes = encoder.encode(str); + const binaryStr = String.fromCharCode(...charCodes); + return btoa(binaryStr).replace(/=/g, "").replace(/\+/g, "-").replace(/\//g, "_"); +} +function pemToBinary(pem) { + return base64StringToUint8Array(pem.replace(/-+(BEGIN|END).*/g, "").replace(/\s/g, "")); +} +async function importTextSecret(key, algorithm, keyUsages) { + return await crypto.subtle.importKey("raw", textToUint8Array(key), algorithm, true, keyUsages); +} +async function importJwk(key, algorithm, keyUsages) { + return await crypto.subtle.importKey("jwk", key, algorithm, true, keyUsages); +} +async function importPublicKey(key, algorithm, keyUsages) { + return await crypto.subtle.importKey("spki", pemToBinary(key), algorithm, true, keyUsages); +} +async function importPrivateKey(key, algorithm, keyUsages) { + return await crypto.subtle.importKey("pkcs8", pemToBinary(key), algorithm, true, keyUsages); +} +async function importKey(key, algorithm, keyUsages) { + if (typeof key === "object") + return importJwk(key, algorithm, keyUsages); + if (typeof key !== "string") + throw new Error("Unsupported key type!"); + if (key.includes("PUBLIC")) + return importPublicKey(key, algorithm, keyUsages); + if (key.includes("PRIVATE")) + return importPrivateKey(key, algorithm, keyUsages); + return importTextSecret(key, algorithm, keyUsages); +} +function decodePayload(raw) { + try { + const bytes = Array.from(atob(raw), (char) => char.charCodeAt(0)); + const decodedString = new TextDecoder("utf-8").decode(new Uint8Array(bytes)); + return JSON.parse(decodedString); + } catch { + return; + } +} if (typeof crypto === "undefined" || !crypto.subtle) - throw new Error("SubtleCrypto not supported!"); + throw new Error("SubtleCrypto not supported!"); var algorithms = { - ES256: { name: "ECDSA", namedCurve: "P-256", hash: { name: "SHA-256" } }, - HS256: { name: "HMAC", hash: { name: "SHA-256" } } + ES256: { name: "ECDSA", namedCurve: "P-256", hash: { name: "SHA-256" } }, + HS256: { name: "HMAC", hash: { name: "SHA-256" } } }; async function sign(payload, secret, options = "HS256") { - if (typeof options === "string") - options = { algorithm: options }; - options = { algorithm: "HS256", header: { typ: "JWT", ...options.header ?? {} }, ...options }; - if (!payload || typeof payload !== "object") - throw new Error("payload must be an object"); - if (!secret || (typeof secret !== "string" && typeof secret !== "object")) - throw new Error("secret must be a string, a JWK object or a CryptoKey object"); - if (typeof options.algorithm !== "string") - throw new Error("options.algorithm must be a string"); - if (!(options.algorithm === "HS256" || options.algorithm === "ES256")) - throw new Error("Only HS256 and ES256 algorithms are supported"); - const algorithm = algorithms[options.algorithm]; - if (!algorithm) - throw new Error("algorithm not found"); - if (!payload.iat) - payload.iat = Math.floor(Date.now() / 1e3); - const partialToken = `${textToBase64Url(JSON.stringify({ ...options.header, alg: options.algorithm }))}.${textToBase64Url(JSON.stringify(payload))}`; - const key = secret instanceof CryptoKey ? secret : await importKey(secret, algorithm, ["sign"]); - const signature = await crypto.subtle.sign(algorithm, key, textToUint8Array(partialToken)); - return `${partialToken}.${arrayBufferToBase64Url(signature)}`; + if (typeof options === "string") + options = { algorithm: options }; + options = { algorithm: "HS256", header: { typ: "JWT", ...options.header ?? {} }, ...options }; + if (!payload || typeof payload !== "object") + throw new Error("payload must be an object"); + if (!secret || (typeof secret !== "string" && typeof secret !== "object")) + throw new Error("secret must be a string, a JWK object or a CryptoKey object"); + if (typeof options.algorithm !== "string") + throw new Error("options.algorithm must be a string"); + if (!(options.algorithm === "HS256" || options.algorithm === "ES256")) + throw new Error("Only HS256 and ES256 algorithms are supported"); + const algorithm = algorithms[options.algorithm]; + if (!algorithm) + throw new Error("algorithm not found"); + if (!payload.iat) + payload.iat = Math.floor(Date.now() / 1e3); + const partialToken = `${textToBase64Url(JSON.stringify({ ...options.header, alg: options.algorithm }))}.${textToBase64Url(JSON.stringify(payload))}`; + const key = secret instanceof CryptoKey ? secret : await importKey(secret, algorithm, ["sign"]); + const signature = await crypto.subtle.sign(algorithm, key, textToUint8Array(partialToken)); + return `${partialToken}.${arrayBufferToBase64Url(signature)}`; } async function verify(token, secret, options = "HS256") { - if (typeof options === "string") - options = { algorithm: options }; - options = { algorithm: "HS256", clockTolerance: 0, throwError: false, ...options }; - if (typeof token !== "string") - throw new Error("token must be a string"); - if (typeof secret !== "string" && typeof secret !== "object") - throw new Error("secret must be a string, a JWK object or a CryptoKey object"); - if (typeof options.algorithm !== "string") - throw new Error("options.algorithm must be a string"); - if (!(options.algorithm === "HS256" || options.algorithm === "ES256")) - throw new Error("Only HS256 and ES256 algorithms are supported"); - const tokenParts = token.split("."); - if (tokenParts.length !== 3) - throw new Error("token must consist of 3 parts"); - const algorithm = algorithms[options.algorithm]; - if (!algorithm) - throw new Error("algorithm not found"); - const decodedToken = decode(token); - try { - if (decodedToken.header?.alg !== options.algorithm) - throw new Error("INVALID_SIGNATURE"); - if (decodedToken.payload) { - const now = Math.floor(Date.now() / 1e3); - if (decodedToken.payload.nbf && decodedToken.payload.nbf > now && decodedToken.payload.nbf - now > (options.clockTolerance ?? 0)) - throw new Error("NOT_YET_VALID"); - if (decodedToken.payload.exp && decodedToken.payload.exp <= now && now - decodedToken.payload.exp > (options.clockTolerance ?? 0)) - throw new Error("EXPIRED"); - } - const key = secret instanceof CryptoKey ? secret : await importKey(secret, algorithm, ["verify"]); - if (!await crypto.subtle.verify(algorithm, key, base64UrlToUint8Array(tokenParts[2]), textToUint8Array(`${tokenParts[0]}.${tokenParts[1]}`))) - throw new Error("INVALID_SIGNATURE"); - return decodedToken; - } catch (err) { - if (options.throwError) - throw err; - return; - } + if (typeof options === "string") + options = { algorithm: options }; + options = { algorithm: "HS256", clockTolerance: 0, throwError: false, ...options }; + if (typeof token !== "string") + throw new Error("token must be a string"); + if (typeof secret !== "string" && typeof secret !== "object") + throw new Error("secret must be a string, a JWK object or a CryptoKey object"); + if (typeof options.algorithm !== "string") + throw new Error("options.algorithm must be a string"); + if (!(options.algorithm === "HS256" || options.algorithm === "ES256")) + throw new Error("Only HS256 and ES256 algorithms are supported"); + const tokenParts = token.split("."); + if (tokenParts.length !== 3) + throw new Error("token must consist of 3 parts"); + const algorithm = algorithms[options.algorithm]; + if (!algorithm) + throw new Error("algorithm not found"); + const decodedToken = decode(token); + try { + if (decodedToken.header?.alg !== options.algorithm) + throw new Error("INVALID_SIGNATURE"); + if (decodedToken.payload) { + const now = Math.floor(Date.now() / 1e3); + if (decodedToken.payload.nbf && decodedToken.payload.nbf > now && decodedToken.payload.nbf - now > (options.clockTolerance ?? 0)) + throw new Error("NOT_YET_VALID"); + if (decodedToken.payload.exp && decodedToken.payload.exp <= now && now - decodedToken.payload.exp > (options.clockTolerance ?? 0)) + throw new Error("EXPIRED"); + } + const key = secret instanceof CryptoKey ? secret : await importKey(secret, algorithm, ["verify"]); + if (!await crypto.subtle.verify(algorithm, key, base64UrlToUint8Array(tokenParts[2]), textToUint8Array(`${tokenParts[0]}.${tokenParts[1]}`))) + throw new Error("INVALID_SIGNATURE"); + return decodedToken; + } catch (err) { + if (options.throwError) + throw err; + return; + } } function decode(token) { - return { - header: decodePayload(token.split(".")[0].replace(/-/g, "+").replace(/_/g, "/")), - payload: decodePayload(token.split(".")[1].replace(/-/g, "+").replace(/_/g, "/")) - }; + return { + header: decodePayload(token.split(".")[0].replace(/-/g, "+").replace(/_/g, "/")), + payload: decodePayload(token.split(".")[1].replace(/-/g, "+").replace(/_/g, "/")) + }; } var index_default = { - sign, - verify, - decode + sign, + verify, + decode }; // src/index.js var establishContext = (env) => { - const approovSecretRaw = env.APPROOV_SECRET_BASE64.trim(); - let approovSecret; - if (approovSecretRaw.includes("PUBLIC KEY")) { - approovSecret = approovSecretRaw.replace(/-----BEGIN PUBLIC KEY-----/g, "").replace(/-----END PUBLIC KEY-----/g, "").replace(/\n/g, "").trim(); - } else { - // Accept both base64url and standard base64 + const approovSecretRaw = env.APPROOV_SECRET_BASE64.trim(); + let approovSecret; + if (approovSecretRaw.includes("PUBLIC KEY")) { + approovSecret = approovSecretRaw.replace(/-----BEGIN PUBLIC KEY-----/g, "").replace(/-----END PUBLIC KEY-----/g, "").replace(/\n/g, "").trim(); + } else { + // Accept both base64url and standard base64 let b64 = approovSecretRaw; if (/[^A-Za-z0-9+/=]/.test(b64)) { // If contains base64url chars, convert to base64 @@ -163,107 +163,134 @@ var establishContext = (env) => { } b64 = b64.padEnd(Math.ceil(b64.length / 4) * 4, "="); approovSecret = atob(b64); - } - const ctx = { - approovSecret, - approovTokenHeaderName: env.APPROOV_TOKEN_HEADER_NAME || "Approov-Token", - approovBindingHeaderName: env.APPROOV_BINDING_HEADER_NAME || "Authorization", - approovBindingClaimName: "pay", - approovBindingVerification: env.APPROOV_BINDING_VERIFICATION || false, - isValid: !!approovSecret - }; - return ctx; + } + const ctx = { + approovSecret, + approovTokenHeaderName: env.APPROOV_TOKEN_HEADER_NAME || "Approov-Token", + approovBindingHeaderName: env.APPROOV_BINDING_HEADER_NAME || "Authorization", + approovBindingClaimName: "pay", + approovBindingVerification: env.APPROOV_BINDING_VERIFICATION || false, + isValid: !!approovSecret + }; + return ctx; }; var extractToken = (ctx, request) => { - return request.headers.get(ctx.approovTokenHeaderName); + return request.headers.get(ctx.approovTokenHeaderName); }; var validateToken = async (ctx, token) => { - if (!ctx || !token) return { valid: false, reason: "No context or token" }; - const { header } = index_default.decode(token); - if (!header || !header.alg) return { valid: false, reason: "No header or alg" }; - const allowedAlgorithms = ["ES256", "HS256"]; - const algorithm = header.alg; - if (!allowedAlgorithms.includes(algorithm)) { - return { valid: false, reason: `Unsupported JWT algorithm: ${algorithm}` }; - } - const options = { algorithm, throwError: true }; - try { - const result = await index_default.verify(token, ctx.approovSecret, options); - if (!result) return { valid: false, reason: "Unknown verification failure" }; - return { valid: true }; - } catch (err) { - return { valid: false, reason: err && err.message ? err.message : String(err) }; - } + if (!ctx || !token) return { valid: false, reason: "No context or token" }; + const { header } = index_default.decode(token); + if (!header || !header.alg) return { valid: false, reason: "No header or alg" }; + const allowedAlgorithms = ["ES256", "HS256"]; + const algorithm = header.alg; + if (!allowedAlgorithms.includes(algorithm)) { + return { valid: false, reason: `Unsupported JWT algorithm: ${algorithm}` }; + } + const options = { algorithm, throwError: true }; + try { + const result = await index_default.verify(token, ctx.approovSecret, options); + if (!result) return { valid: false, reason: "Unknown verification failure" }; + return { valid: true }; + } catch (err) { + return { valid: false, reason: err && err.message ? err.message : String(err) }; + } }; var extractBinding = (ctx, request) => { - return request.headers.get(ctx.approovBindingHeaderName); + return request.headers.get(ctx.approovBindingHeaderName); }; var validateBinding = async (ctx, token, binding) => { - if (!ctx || !token || !binding) return false; - const encoder = new TextEncoder(); - const data = encoder.encode(binding); - const buffer = await crypto.subtle.digest("SHA-256", data); - let binary = ""; - const bytes = new Uint8Array(buffer); - const len = bytes.byteLength; - for (let i = 0; i < len; i++) { - binary += String.fromCharCode(bytes[i]); - } - const hash = btoa(binary); - const { payload } = index_default.decode(token); - const claim = payload ? payload[ctx.approovBindingClaimName] : null; - if (!claim) return false; - return claim === hash; + if (!ctx || !token || !binding) return false; + const encoder = new TextEncoder(); + const data = encoder.encode(binding); + const buffer = await crypto.subtle.digest("SHA-256", data); + let binary = ""; + const bytes = new Uint8Array(buffer); + const len = bytes.byteLength; + for (let i = 0; i < len; i++) { + binary += String.fromCharCode(bytes[i]); + } + const hash = btoa(binary); + const { payload } = index_default.decode(token); + const claim = payload ? payload[ctx.approovBindingClaimName] : null; + if (!claim) return false; + return claim === hash; }; var handleRequest = async (request, env) => { - // Log the request headers for debugging purposes - console.log('Request Headers:'); - for (const [key, value] of request.headers.entries()) { - console.log(`${key}: ${value}`); - } - const ctx = establishContext(env); - if (!ctx.isValid) { - console.error(`CONTEXT ERROR: Unable to establish context; check environmental values and secrets`); - return new Response("internal server error", { status: 500 }); - } - - const approovToken = extractToken(ctx, request); - if (!approovToken) { - console.error(`AUTH FAILURE: Approov token not found`); - return new Response("unauthorized", { status: 401 }); + // DEBUG + // Log the request headers for debugging purposes + console.log('Request Headers:'); + for (const [key, value] of request.headers.entries()) { + console.log(`${key}: ${value}`); } + // END DEBUG + + const ctx = establishContext(env); + if (!ctx.isValid) { + console.error(`CONTEXT ERROR: Unable to establish context; check environmental values and secrets`); + return new Response( + JSON.stringify({ error: `SERVER ERROR: unable to establish context` }), + { + status: 500, + headers: { 'Content-Type': 'application/json' } + }); + } - const tokenResult = await validateToken(ctx, approovToken); - if (!tokenResult.valid) { - console.error(`AUTH FAILURE: ${tokenResult.reason}`); - return new Response("unauthorized", { status: 401 }); - } + const approovToken = extractToken(ctx, request); + if (!approovToken) { + console.error(`AUTH FAILURE: Approov token not found`); + return new Response( + JSON.stringify({ error: `UNAUTHORIZED: Approov token not found` }), + { + status: 401, + headers: { 'Content-Type': 'application/json' } + }); + } + // Extract the arc claim from the JWT token payload (without validation) only if present + let arcClaim; + try { + const decoded = index_default.decode(approovToken); + arcClaim = decoded && decoded.payload ? decoded.payload.arc : undefined; + } catch (e) { + // default setting meaning the token does not contain "arc" claim + arcClaim = "unknown-arc-claim"; + } + // arcClaim is now available for later use + const tokenResult = await validateToken(ctx, approovToken); + if (!tokenResult.valid) { + console.error(`AUTH FAILURE: ${tokenResult.reason} arc: ${arcClaim}`); + return new Response( + JSON.stringify({ error: `AUTH FAILURE: ${tokenResult.reason} arc: ${arcClaim}` }), + { + status: 401, + headers: { 'Content-Type': 'application/json' } + }); + } - if (ctx.approovBindingVerification) { - const approovBinding = extractBinding(ctx, request); - isAuthorized = await validateBinding(ctx, approovToken, approovBinding); - if (!isAuthorized) { - console.error(`AUTH FAILURE: Approov token binding missing or invalid`); - return new Response("unauthorized", { status: 401 }); - } - } + if (ctx.approovBindingVerification) { + const approovBinding = extractBinding(ctx, request); + const isAuthorized = await validateBinding(ctx, approovToken, approovBinding); + if (!isAuthorized) { + console.error(`AUTH FAILURE: Approov token binding missing or invalid`); + return new Response( + JSON.stringify({ error: `AUTH FAILURE: Approov token binding missing or invalid` }), + { + status: 401, + headers: { 'Content-Type': 'application/json' } + }); + } + } - // Forward the request to the original URL (preserving method, headers, and body) - // Remove the Approov token header before forwarding - const url = new URL(request.url); - // Optionally, you may want to delete the Approov token header (as in original.js) - const forwardRequest = new Request(url.toString(), request); - //forwardRequest.headers.delete(ctx.approovTokenHeaderName); + // Forward the request to the original URL (preserving method, headers, and body) + // Remove the Approov token header before forwarding + const url = new URL(request.url); + const forwardRequest = new Request(url, request); + // Optionally, you may want to delete the Approov token header (as in original.js) + //forwardRequest.headers.delete(ctx.approovTokenHeaderName); - // Forward the request and return the response - return fetch(forwardRequest); -}; -var index_default2 = { - async fetch(request, env) { - return await handleRequest(request, env); - } + // Forward the request and return the response + return fetch(forwardRequest); }; -export { - index_default2 as default +export default { + fetch: handleRequest }; From fc7180ed54410397462370af06772f23f32b91fe Mon Sep 17 00:00:00 2001 From: ivo liondov Date: Wed, 24 Sep 2025 14:58:27 +0100 Subject: [PATCH 7/8] Update index.js Comment out DEBUG statement --- src/index.js | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/index.js b/src/index.js index cea3880..c25866f 100644 --- a/src/index.js +++ b/src/index.js @@ -218,10 +218,10 @@ var validateBinding = async (ctx, token, binding) => { var handleRequest = async (request, env) => { // DEBUG // Log the request headers for debugging purposes - console.log('Request Headers:'); - for (const [key, value] of request.headers.entries()) { - console.log(`${key}: ${value}`); - } + //console.log('Request Headers:'); + //for (const [key, value] of request.headers.entries()) { + //console.log(`${key}: ${value}`); + //} // END DEBUG const ctx = establishContext(env); From 8293a4d0431a2179ccc45ef5b748789a1118dc6b Mon Sep 17 00:00:00 2001 From: ivo liondov Date: Fri, 26 Sep 2025 09:25:25 +0100 Subject: [PATCH 8/8] Update index.js Format return error with APPROOV prefix in json --- src/index.js | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/index.js b/src/index.js index c25866f..40586d2 100644 --- a/src/index.js +++ b/src/index.js @@ -228,7 +228,7 @@ var handleRequest = async (request, env) => { if (!ctx.isValid) { console.error(`CONTEXT ERROR: Unable to establish context; check environmental values and secrets`); return new Response( - JSON.stringify({ error: `SERVER ERROR: unable to establish context` }), + JSON.stringify({ error: `APPROOV SERVER ERROR: unable to establish context` }), { status: 500, headers: { 'Content-Type': 'application/json' } @@ -239,7 +239,7 @@ var handleRequest = async (request, env) => { if (!approovToken) { console.error(`AUTH FAILURE: Approov token not found`); return new Response( - JSON.stringify({ error: `UNAUTHORIZED: Approov token not found` }), + JSON.stringify({ error: `APPROOV UNAUTHORIZED: Approov token not found` }), { status: 401, headers: { 'Content-Type': 'application/json' } @@ -259,7 +259,7 @@ var handleRequest = async (request, env) => { if (!tokenResult.valid) { console.error(`AUTH FAILURE: ${tokenResult.reason} arc: ${arcClaim}`); return new Response( - JSON.stringify({ error: `AUTH FAILURE: ${tokenResult.reason} arc: ${arcClaim}` }), + JSON.stringify({ error: `APPROOV AUTH FAILURE: ${tokenResult.reason}`, arc: `${arcClaim}` }), { status: 401, headers: { 'Content-Type': 'application/json' } @@ -272,7 +272,7 @@ var handleRequest = async (request, env) => { if (!isAuthorized) { console.error(`AUTH FAILURE: Approov token binding missing or invalid`); return new Response( - JSON.stringify({ error: `AUTH FAILURE: Approov token binding missing or invalid` }), + JSON.stringify({ error: `APPROOV AUTH FAILURE: Approov token binding missing or invalid` }), { status: 401, headers: { 'Content-Type': 'application/json' }