Skip to content

Commit 9819ca8

Browse files
committed
add structured logging and update token binding logic
1 parent 8739c3d commit 9819ca8

5 files changed

Lines changed: 187 additions & 83 deletions

File tree

ApproovApplication.js

Lines changed: 150 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,8 @@ if (envResult.error && envResult.error.code !== 'ENOENT') {
1414
const PORT = parsePort(process.env.PORT, 8080);
1515
const APPROOV_HEADER = 'Approov-Token';
1616
const AUTH_HEADER = 'Authorization';
17-
const DIGEST_HEADER = 'Content-Digest';
17+
const SESSION_ID_HEADER = 'SessionId';
18+
const REQUIRED_SECRET_PLACEHOLDER = 'approov_base64url_secret_here';
1819
const APPROOV_SECRET = loadApproovSecret();
1920

2021
let approovEnabled = true;
@@ -27,10 +28,20 @@ const PROTECTED_PATHS = new Set([
2728
'/token-double-binding',
2829
]);
2930

31+
const APPROOV_ERROR_CODES = {
32+
MISSING_TOKEN: 'missing_approov_token',
33+
TOKEN_VERIFICATION_FAILED: 'token_verification_failed',
34+
TOKEN_MISSING_EXPIRATION: 'token_missing_expiration',
35+
TOKEN_EXPIRED: 'token_expired',
36+
MISSING_BINDING_HEADER: 'missing_binding_header',
37+
BINDING_MISMATCH: 'binding_mismatch',
38+
};
39+
3040
const app = express();
3141
app.disable('x-powered-by');
3242
app.use(cors());
3343

44+
app.use(requestLoggingMiddleware);
3445
app.use(approovAuthMiddleware);
3546

3647
app.get('/', (req, res) => {
@@ -78,10 +89,10 @@ app.get('/token-binding', (req, res) => {
7889

7990
app.get('/token-double-binding', (req, res) => {
8091
const authorization = req.get(AUTH_HEADER);
81-
const contentDigest = req.get(DIGEST_HEADER);
92+
const sessionId = req.get(SESSION_ID_HEADER);
8293
const payload = infoPayload("Protected endpoint '/token-double-binding'; dual token binding enforced.");
8394
payload.authorizationHeaderPresent = hasText(authorization);
84-
payload.contentDigestHeaderPresent = hasText(contentDigest);
95+
payload.sessionIdHeaderPresent = hasText(sessionId);
8596
res.json(payload);
8697
});
8798

@@ -104,27 +115,45 @@ function approovAuthMiddleware(req, res, next) {
104115
}
105116

106117
if (!PROTECTED_PATHS.has(req.path)) {
118+
req.approovSummary = 'approov_not_required';
119+
req.approovRequiredHeaders = [];
107120
return next();
108121
}
109122

123+
req.approovRequiredHeaders = requiredHeadersFor(req.path, approovEnabled, tokenBindingEnabled);
124+
110125
if (!approovEnabled) {
126+
req.approovSummary = 'approov_disabled';
111127
return next();
112128
}
113129

114130
try {
115131
const rawToken = trimOrNull(req.get(APPROOV_HEADER));
116132
const claims = verifyApproovToken(rawToken);
117133

118-
if (tokenBindingEnabled && needsBindingCheck(req.path)) {
119-
const bindingValue = extractBindingValue(req.path, req);
120-
if (!hasText(bindingValue)) {
121-
throw new ApproovAuthError('Missing binding header value.');
134+
if (tokenBindingEnabled) {
135+
const bindingHeaders = bindingHeadersFor(req.path);
136+
if (bindingHeaders.length > 0) {
137+
const bindingValue = extractBindingValue(req, bindingHeaders);
138+
if (!hasText(bindingValue)) {
139+
throw new ApproovAuthError(
140+
APPROOV_ERROR_CODES.MISSING_BINDING_HEADER,
141+
'Missing binding header value.'
142+
);
143+
}
144+
if (!isBindingValid(bindingValue, claims)) {
145+
throw new ApproovAuthError(
146+
APPROOV_ERROR_CODES.BINDING_MISMATCH,
147+
'Approov token binding mismatch.'
148+
);
149+
}
122150
}
123-
verifyTokenBinding(bindingValue, claims);
124151
}
125152

153+
req.approovSummary = 'approov_ok';
126154
return next();
127155
} catch (err) {
156+
setApproovFailureSummary(req, err);
128157
logAuthFailure(err);
129158
return unauthorized(res);
130159
}
@@ -133,7 +162,7 @@ function approovAuthMiddleware(req, res, next) {
133162
// Token validation logic: signature check, expiration, and binding (when enabled).
134163
function verifyApproovToken(token) {
135164
if (!hasText(token)) {
136-
throw new ApproovAuthError('Approov token missing.');
165+
throw new ApproovAuthError(APPROOV_ERROR_CODES.MISSING_TOKEN, 'Approov token missing.');
137166
}
138167

139168
let claims;
@@ -143,47 +172,77 @@ function verifyApproovToken(token) {
143172
ignoreExpiration: true,
144173
});
145174
} catch (err) {
146-
throw new ApproovAuthError('Approov token verification failed.');
175+
throw new ApproovAuthError(
176+
APPROOV_ERROR_CODES.TOKEN_VERIFICATION_FAILED,
177+
'Approov token verification failed.'
178+
);
147179
}
148180

149181
const exp = Number(claims.exp);
150182
if (!Number.isFinite(exp)) {
151-
throw new ApproovAuthError('Approov token missing expiration.');
183+
throw new ApproovAuthError(
184+
APPROOV_ERROR_CODES.TOKEN_MISSING_EXPIRATION,
185+
'Approov token missing expiration.'
186+
);
152187
}
153188
if (exp * 1000 <= Date.now()) {
154-
throw new ApproovAuthError('Approov token expired.');
189+
throw new ApproovAuthError(APPROOV_ERROR_CODES.TOKEN_EXPIRED, 'Approov token expired.');
155190
}
156191

157192
return claims;
158193
}
159194

160-
function verifyTokenBinding(bindingValue, claims) {
195+
function isBindingValid(bindingValue, claims) {
161196
const expected = typeof claims.pay === 'string' ? claims.pay.trim() : '';
162197
if (!hasText(expected)) {
163-
throw new ApproovAuthError('Approov token missing binding payload.');
198+
return false;
164199
}
165200

166201
const computed = hashBase64(bindingValue);
167-
if (computed !== expected) {
168-
throw new ApproovAuthError('Approov token binding mismatch.');
169-
}
202+
return timingSafeEquals(expected, computed);
170203
}
171204

172-
function extractBindingValue(path, req) {
173-
if (path === '/token-binding') {
174-
return trimOrNull(req.get(AUTH_HEADER));
205+
function extractBindingValue(req, bindingHeaders) {
206+
if (bindingHeaders.length === 0) {
207+
return null;
175208
}
176209

177-
const authorization = trimOrNull(req.get(AUTH_HEADER));
178-
const digest = trimOrNull(req.get(DIGEST_HEADER));
179-
if (!hasText(authorization) || !hasText(digest)) {
210+
const values = [];
211+
for (const header of bindingHeaders) {
212+
const value = trimOrNull(req.get(header));
213+
if (!hasText(value)) {
214+
return null;
215+
}
216+
values.push(value);
217+
}
218+
219+
if (values.length === 0) {
180220
return null;
181221
}
182-
return authorization + digest;
222+
223+
return values.join('');
224+
}
225+
226+
function bindingHeadersFor(path) {
227+
if (path === '/token-binding') {
228+
return [AUTH_HEADER];
229+
}
230+
if (path === '/token-double-binding') {
231+
return [AUTH_HEADER, SESSION_ID_HEADER];
232+
}
233+
return [];
183234
}
184235

185-
function needsBindingCheck(path) {
186-
return path === '/token-binding' || path === '/token-double-binding';
236+
function requiredHeadersFor(path, approovState, bindingState) {
237+
if (!PROTECTED_PATHS.has(path) || !approovState) {
238+
return [];
239+
}
240+
241+
const headers = [APPROOV_HEADER];
242+
if (bindingState) {
243+
headers.push(...bindingHeadersFor(path));
244+
}
245+
return headers;
187246
}
188247

189248
function enableApproov() {
@@ -212,7 +271,8 @@ function infoPayload(details) {
212271

213272
function loadApproovSecret() {
214273
const raw = process.env.APPROOV_BASE64URL_SECRET;
215-
if (!hasText(raw)) {
274+
if (!hasText(raw) || raw.trim() === REQUIRED_SECRET_PLACEHOLDER) {
275+
console.error('[Approov] Required secret is not set');
216276
throw new Error('APPROOV_BASE64URL_SECRET environment variable is not set.');
217277
}
218278

@@ -223,6 +283,7 @@ function loadApproovSecret() {
223283
}
224284
return decoded;
225285
} catch (err) {
286+
console.error('[Approov] Required secret is invalid');
226287
throw new Error('APPROOV_BASE64URL_SECRET must be base64url encoded.');
227288
}
228289
}
@@ -231,18 +292,77 @@ function hashBase64(value) {
231292
return crypto.createHash('sha256').update(value, 'utf8').digest('base64');
232293
}
233294

295+
function timingSafeEquals(expected, actual) {
296+
const expectedBuffer = Buffer.from(expected, 'utf8');
297+
const actualBuffer = Buffer.from(actual, 'utf8');
298+
if (expectedBuffer.length !== actualBuffer.length) {
299+
return false;
300+
}
301+
return crypto.timingSafeEqual(expectedBuffer, actualBuffer);
302+
}
303+
234304
function unauthorized(res) {
235305
res.status(401).json({});
236306
}
237307

238308
function logAuthFailure(err) {
239309
if (err instanceof ApproovAuthError) {
240-
console.warn(`[Approov] ${err.message}`);
310+
console.warn(`[Approov] ${err.code}: ${err.message}`);
241311
return;
242312
}
243313
console.warn('[Approov] Unexpected authentication error.', err);
244314
}
245315

316+
function requestLoggingMiddleware(req, res, next) {
317+
res.on('finish', () => {
318+
if (res.statusCode !== 200 && res.statusCode !== 401) {
319+
return;
320+
}
321+
322+
const summary = typeof req.approovSummary === 'string'
323+
? req.approovSummary
324+
: res.statusCode === 401
325+
? 'approov_failed:unauthorized'
326+
: 'request_completed';
327+
const requiredHeaders = Array.isArray(req.approovRequiredHeaders)
328+
? req.approovRequiredHeaders
329+
: requiredHeadersFor(req.path, approovEnabled, tokenBindingEnabled);
330+
logRequestCompleted(req, res, summary, requiredHeaders);
331+
});
332+
333+
next();
334+
}
335+
336+
function logRequestCompleted(req, res, summary, requiredHeaders) {
337+
const payload = {
338+
summary,
339+
method: req.method,
340+
path: req.path,
341+
status: res.statusCode,
342+
ip: req.ip,
343+
port: req.socket?.localPort ?? PORT,
344+
approovEnabled,
345+
tokenBindingEnabled,
346+
required_headers: requiredHeaders,
347+
};
348+
console.log(`[${formatTimestamp(new Date())}] http.request.completed ${JSON.stringify(payload)}`);
349+
}
350+
351+
function formatTimestamp(date) {
352+
const pad = (value) => String(value).padStart(2, '0');
353+
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(
354+
date.getHours()
355+
)}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`;
356+
}
357+
358+
function setApproovFailureSummary(req, err) {
359+
if (err instanceof ApproovAuthError && hasText(err.code)) {
360+
req.approovSummary = `approov_failed:${err.code}`;
361+
return;
362+
}
363+
req.approovSummary = 'approov_failed:unexpected_error';
364+
}
365+
246366
function hasText(value) {
247367
return typeof value === 'string' && value.trim() !== '';
248368
}
@@ -257,8 +377,9 @@ function parsePort(value, fallback) {
257377
}
258378

259379
class ApproovAuthError extends Error {
260-
constructor(message) {
380+
constructor(code, message) {
261381
super(message);
262382
this.name = 'ApproovAuthError';
383+
this.code = code;
263384
}
264385
}

README.md

Lines changed: 25 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,22 @@ This project provides a server-side example of Approov token verification for a
77
- `/token-binding` - requires a valid Approov token which is bound to a header value.
88
- `/token-double-binding` - requires a valid Approov token which is bound to two header values.
99

10-
In this example, Approov protection is enforced by [app.use(approovAuthMiddleware)](https://github.com/approov/quickstart-nodejs-express-token-check/blob/refactor/nodejs-express-quickstart/ApproovApplication.js#L101-L131). The middleware reads the `Approov-Token` header, validates signature and expiry via [verifyApproovToken](https://github.com/approov/quickstart-nodejs-express-token-check/blob/refactor/nodejs-express-quickstart/ApproovApplication.js#L134-L158), and (when required) enforces token binding via [verifyTokenBinding](https://github.com/approov/quickstart-nodejs-express-token-check/blob/refactor/nodejs-express-quickstart/ApproovApplication.js#L160-L170). Protected routes are selected via [PROTECTED_PATHS](https://github.com/approov/quickstart-nodejs-express-token-check/blob/refactor/nodejs-express-quickstart/ApproovApplication.js#L24-L28).
10+
In this example, Approov token check is implemented in `ApproovApplication.js`. The responsibilities break down as follows:
11+
12+
1. **JWT Approov Token validation (signature + expiry)** is implemented in [verifyApproovToken](https://github.com/approov/quickstart-nodejs-express-token-check/blob/refactor/nodejs-express-quickstart/ApproovApplication.js#L162-L193)
13+
It verifies the HS256 signature (via `jwt.verify`) and rejects tokens that are missing or past `exp`.
14+
15+
2. **Token binding (pay + hash)** is handled by [isBindingValid](https://github.com/approov/quickstart-nodejs-express-token-check/blob/refactor/nodejs-express-quickstart/ApproovApplication.js#L195-L203) and [hashBase64](ApproovApplication.js#L291-L293)
16+
It computes `base64(sha256(binding_value))` and compares it to `pay` using `timingSafeEquals`.
17+
18+
3. **Middleware enforcement** is done by [approovAuthMiddleware](https://github.com/approov/quickstart-nodejs-express-token-check/blob/refactor/nodejs-express-quickstart/ApproovApplication.js#L111-L160)
19+
Requests without valid token/binding are rejected with 401.
20+
21+
4. **Binding value selection (what gets hashed)** is in [extractBindingValue + bindingHeadersFor](https://github.com/approov/quickstart-nodejs-express-token-check/blob/refactor/nodejs-express-quickstart/ApproovApplication.js#L205-L234) It uses the headers configured in `bindingHeadersFor` (currently `Authorization` for single binding, or `Authorization` + `SessionId` for double binding).
22+
23+
5. **Protected route requirements** are defined in [PROTECTED_PATHS](https://github.com/approov/quickstart-nodejs-express-token-check/blob/refactor/nodejs-express-quickstart/ApproovApplication.js#L24-L29) and [requiredHeadersFor](https://github.com/approov/quickstart-nodejs-express-token-check/blob/refactor/nodejs-express-quickstart/ApproovApplication.js#L236-L246)
24+
25+
6. **Protected routes are registered** in the Express route declarations ([app.get/app.post](https://github.com/approov/quickstart-nodejs-express-token-check/blob/refactor/nodejs-express-quickstart/ApproovApplication.js#L47-L97))
1126

1227
## Approov Token Verification Flow
1328

@@ -29,8 +44,8 @@ In this example, Approov protection is enforced by [app.use(approovAuthMiddlewar
2944
The protected API then computes the same hash from the incoming request and verifies that it matches the `pay` claim, preventing token reuse or replay attacks. For local testing, you can also generate example tokens with a binding using the Approov CLI.
3045

3146
5. **Request Decision:**
32-
If all checks pass → the request is trusted and processed `200 OK`.
33-
If validation fails → the server responds with `401 Unauthorized`.
47+
If all checks pass → the request is trusted and processed `200 OK`.
48+
If validation fails → the server responds with `401 Unauthorized`.
3449

3550
## Requirements:
3651

@@ -78,7 +93,7 @@ bash test.sh
7893
This script:
7994
- Verifies that the `approov` and `curl` commands are installed.
8095
- Checks Approov status by calling `/approov-state` (enabled vs disabled).
81-
- Runs endpoint tests against `/unprotected` (no token), `/token-check` (valid/invalid Approov tokens), `/token-binding` (token bound to `Authorization`), and `/token-double-binding` (token bound to `Authorization` + `Content-Digest`).
96+
- Runs endpoint tests against `/unprotected` (no token), `/token-check` (valid/invalid Approov tokens), `/token-binding` (token bound to `Authorization`), and `/token-double-binding` (token bound to `Authorization` + `SessionId`).
8297
- Logs full request/response details to `.config/logs/<timestamp>.log`.
8398

8499
#### *1. Unprotected Endpoint (No Approov)*
@@ -175,16 +190,16 @@ Cache-Control: no-cache
175190
- The client sends three headers on authenticated API calls:
176191
- `Approov-Token`
177192
- `Authorization`
178-
- `Content-Digest` It is combined with the `Authorization` header to create a stronger binding.
193+
- `SessionId` It is combined with the `Authorization` header to create a stronger binding.
179194
- Both are included in the hash inside the Approov token. This means the server verifies a single hash that covers both authentication credentials.
180195
- **Use case:** Stronger protection then single binding by tying both headers together.
181196

182197
***The following example shows how the API responds when an Approov token with two bindings is required.***
183198

184-
*Generate a valid Approov token bound to the `Authorization` and `Content-Digest` headers:*
199+
*Generate a valid Approov token bound to the `Authorization` and `SessionId` headers:*
185200

186201
```bash
187-
approov token -setDataHashInToken ExampleAuthToken==ContentDigest== -genExample example.com
202+
approov token -setDataHashInToken ExampleAuthToken==123 -genExample example.com
188203
```
189204

190205
*Use the generated token with two bindings in the Approov-Token and Authorization headers when calling the `/token-double-binding` endpoint.*
@@ -193,7 +208,7 @@ approov token -setDataHashInToken ExampleAuthToken==ContentDigest== -genExample
193208
curl -iX GET http://localhost:8080/token-double-binding \
194209
-H "Approov-Token: valid_approov_token_here" \
195210
-H "Authorization: ExampleAuthToken==" \
196-
-H "Content-Digest: ContentDigest=="
211+
-H "SessionId: 123"
197212
```
198213

199214
The response will be `200 OK` for this request.
@@ -206,7 +221,7 @@ Cache-Control: no-cache
206221

207222
*If you use an invalid or missing header or token, the server will respond with `401 Unauthorized`.*
208223

209-
## Enable or Disable Approov Protection
224+
## Enable or Disable Approov Protection
210225

211226
When the example server is running on `localhost:8080`, you can toggle Approov protection with these commands:
212227

@@ -240,4 +255,4 @@ If you encounter any problems while following this guide, or have any other conc
240255
* [Approov Customer Stories](https://approov.io/customer)
241256
* [Approov Support](https://approov.io/info/technical-support)
242257
* [About Us](https://approov.io/company)
243-
* [Contact Us](https://approov.io/info/contact)
258+
* [Contact Us](https://approov.io/info/contact)
File renamed without changes.

0 commit comments

Comments
 (0)