Skip to content

Commit 8739c3d

Browse files
committed
Initial working Node.js + express app
1 parent 6294493 commit 8739c3d

67 files changed

Lines changed: 2338 additions & 4096 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.editorconfig

Lines changed: 0 additions & 9 deletions
This file was deleted.

.env.example

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
# HTTP port the backend listens on
2+
HTTP_PORT=8080
3+
4+
# Approov secret: approov secret -get base64url
5+
APPROOV_BASE64URL_SECRET=approov_base64url_secret_here
6+
7+
# Localhost
8+
SERVER_HOSTNAME=0.0.0.0
9+
10+
# Command that starts your server inside the container
11+
APP_START_CMD=npm start

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,3 +16,4 @@ node_modules/
1616
.env
1717
packages/
1818
!.gitkeep
19+
.config/

ApproovApplication.js

Lines changed: 264 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,264 @@
1+
'use strict';
2+
3+
const crypto = require('crypto');
4+
const express = require('express');
5+
const cors = require('cors');
6+
const jwt = require('jsonwebtoken');
7+
const dotenv = require('dotenv');
8+
9+
const envResult = dotenv.config({ quiet: true });
10+
if (envResult.error && envResult.error.code !== 'ENOENT') {
11+
throw new Error(`Failed to load .env: ${envResult.error.message}`);
12+
}
13+
14+
const PORT = parsePort(process.env.PORT, 8080);
15+
const APPROOV_HEADER = 'Approov-Token';
16+
const AUTH_HEADER = 'Authorization';
17+
const DIGEST_HEADER = 'Content-Digest';
18+
const APPROOV_SECRET = loadApproovSecret();
19+
20+
let approovEnabled = true;
21+
let tokenBindingEnabled = true;
22+
23+
// Approov-protected endpoints that require token checks.
24+
const PROTECTED_PATHS = new Set([
25+
'/token-check',
26+
'/token-binding',
27+
'/token-double-binding',
28+
]);
29+
30+
const app = express();
31+
app.disable('x-powered-by');
32+
app.use(cors());
33+
34+
app.use(approovAuthMiddleware);
35+
36+
app.get('/', (req, res) => {
37+
res.json(infoPayload('Approov demo API is running on port 8080.'));
38+
});
39+
40+
app.get('/approov-state', (req, res) => {
41+
res.json(statePayload());
42+
});
43+
44+
app.post('/approov/enable', (req, res) => {
45+
enableApproov();
46+
res.json(statePayload());
47+
});
48+
49+
app.post('/approov/disable', (req, res) => {
50+
disableApproov();
51+
res.json(statePayload());
52+
});
53+
54+
app.post('/token-binding/enable', (req, res) => {
55+
tokenBindingEnabled = true;
56+
res.json(statePayload());
57+
});
58+
59+
app.post('/token-binding/disable', (req, res) => {
60+
tokenBindingEnabled = false;
61+
res.json(statePayload());
62+
});
63+
64+
app.get('/unprotected', (req, res) => {
65+
res.json(infoPayload("Unprotected endpoint '/unprotected'; no Approov checks performed."));
66+
});
67+
68+
app.get('/token-check', (req, res) => {
69+
res.json(infoPayload("Protected endpoint '/token-check'; Approov token verified."));
70+
});
71+
72+
app.get('/token-binding', (req, res) => {
73+
const authorization = req.get(AUTH_HEADER);
74+
const payload = infoPayload("Protected endpoint '/token-binding'; Approov token binding enforced.");
75+
payload.authorizationHeaderPresent = hasText(authorization);
76+
res.json(payload);
77+
});
78+
79+
app.get('/token-double-binding', (req, res) => {
80+
const authorization = req.get(AUTH_HEADER);
81+
const contentDigest = req.get(DIGEST_HEADER);
82+
const payload = infoPayload("Protected endpoint '/token-double-binding'; dual token binding enforced.");
83+
payload.authorizationHeaderPresent = hasText(authorization);
84+
payload.contentDigestHeaderPresent = hasText(contentDigest);
85+
res.json(payload);
86+
});
87+
88+
const server = app.listen(PORT, () => {
89+
console.log(`Approov demo API listening on port ${PORT}.`);
90+
});
91+
92+
process.on('SIGTERM', () => shutdown('SIGTERM'));
93+
process.on('SIGINT', () => shutdown('SIGINT'));
94+
95+
function shutdown(signal) {
96+
console.log(`Received ${signal}, shutting down.`);
97+
server.close(() => process.exit(0));
98+
}
99+
100+
// Middleware that enforces Approov token checks on protected endpoints.
101+
function approovAuthMiddleware(req, res, next) {
102+
if (req.method === 'OPTIONS') {
103+
return next();
104+
}
105+
106+
if (!PROTECTED_PATHS.has(req.path)) {
107+
return next();
108+
}
109+
110+
if (!approovEnabled) {
111+
return next();
112+
}
113+
114+
try {
115+
const rawToken = trimOrNull(req.get(APPROOV_HEADER));
116+
const claims = verifyApproovToken(rawToken);
117+
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.');
122+
}
123+
verifyTokenBinding(bindingValue, claims);
124+
}
125+
126+
return next();
127+
} catch (err) {
128+
logAuthFailure(err);
129+
return unauthorized(res);
130+
}
131+
}
132+
133+
// Token validation logic: signature check, expiration, and binding (when enabled).
134+
function verifyApproovToken(token) {
135+
if (!hasText(token)) {
136+
throw new ApproovAuthError('Approov token missing.');
137+
}
138+
139+
let claims;
140+
try {
141+
claims = jwt.verify(token, APPROOV_SECRET, {
142+
algorithms: ['HS256'],
143+
ignoreExpiration: true,
144+
});
145+
} catch (err) {
146+
throw new ApproovAuthError('Approov token verification failed.');
147+
}
148+
149+
const exp = Number(claims.exp);
150+
if (!Number.isFinite(exp)) {
151+
throw new ApproovAuthError('Approov token missing expiration.');
152+
}
153+
if (exp * 1000 <= Date.now()) {
154+
throw new ApproovAuthError('Approov token expired.');
155+
}
156+
157+
return claims;
158+
}
159+
160+
function verifyTokenBinding(bindingValue, claims) {
161+
const expected = typeof claims.pay === 'string' ? claims.pay.trim() : '';
162+
if (!hasText(expected)) {
163+
throw new ApproovAuthError('Approov token missing binding payload.');
164+
}
165+
166+
const computed = hashBase64(bindingValue);
167+
if (computed !== expected) {
168+
throw new ApproovAuthError('Approov token binding mismatch.');
169+
}
170+
}
171+
172+
function extractBindingValue(path, req) {
173+
if (path === '/token-binding') {
174+
return trimOrNull(req.get(AUTH_HEADER));
175+
}
176+
177+
const authorization = trimOrNull(req.get(AUTH_HEADER));
178+
const digest = trimOrNull(req.get(DIGEST_HEADER));
179+
if (!hasText(authorization) || !hasText(digest)) {
180+
return null;
181+
}
182+
return authorization + digest;
183+
}
184+
185+
function needsBindingCheck(path) {
186+
return path === '/token-binding' || path === '/token-double-binding';
187+
}
188+
189+
function enableApproov() {
190+
approovEnabled = true;
191+
tokenBindingEnabled = true;
192+
}
193+
194+
function disableApproov() {
195+
approovEnabled = false;
196+
tokenBindingEnabled = false;
197+
}
198+
199+
function statePayload() {
200+
return {
201+
approovEnabled,
202+
tokenBindingEnabled,
203+
};
204+
}
205+
206+
function infoPayload(details) {
207+
return {
208+
...statePayload(),
209+
details,
210+
};
211+
}
212+
213+
function loadApproovSecret() {
214+
const raw = process.env.APPROOV_BASE64URL_SECRET;
215+
if (!hasText(raw)) {
216+
throw new Error('APPROOV_BASE64URL_SECRET environment variable is not set.');
217+
}
218+
219+
try {
220+
const decoded = Buffer.from(raw.trim(), 'base64url');
221+
if (decoded.length === 0) {
222+
throw new Error('APPROOV_BASE64URL_SECRET decoded to an empty value.');
223+
}
224+
return decoded;
225+
} catch (err) {
226+
throw new Error('APPROOV_BASE64URL_SECRET must be base64url encoded.');
227+
}
228+
}
229+
230+
function hashBase64(value) {
231+
return crypto.createHash('sha256').update(value, 'utf8').digest('base64');
232+
}
233+
234+
function unauthorized(res) {
235+
res.status(401).json({});
236+
}
237+
238+
function logAuthFailure(err) {
239+
if (err instanceof ApproovAuthError) {
240+
console.warn(`[Approov] ${err.message}`);
241+
return;
242+
}
243+
console.warn('[Approov] Unexpected authentication error.', err);
244+
}
245+
246+
function hasText(value) {
247+
return typeof value === 'string' && value.trim() !== '';
248+
}
249+
250+
function trimOrNull(value) {
251+
return typeof value === 'string' ? value.trim() : null;
252+
}
253+
254+
function parsePort(value, fallback) {
255+
const parsed = Number.parseInt(value, 10);
256+
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
257+
}
258+
259+
class ApproovAuthError extends Error {
260+
constructor(message) {
261+
super(message);
262+
this.name = 'ApproovAuthError';
263+
}
264+
}

Dockerfile

Lines changed: 11 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -1,47 +1,16 @@
1-
ARG TAG=17.7.1-slim
1+
# syntax=docker/dockerfile:1
2+
# Builds the quickstart backend container image and configures scripts/install-prerequisites.sh and scripts/build.sh
3+
# as the entrypoint used both locally and when deployed via Docker.
4+
FROM node:22-slim
25

3-
FROM node:${TAG}
6+
ENV APP_HOME=/workspace \
7+
RUN_MODE=container
48

5-
ARG CONTAINER_USER="node"
6-
ARG LANGUAGE_CODE="en"
7-
ARG COUNTRY_CODE="GB"
8-
ARG ENCODING="UTF-8"
9+
WORKDIR /app
910

10-
ARG LOCALE_STRING="${LANGUAGE_CODE}_${COUNTRY_CODE}"
11-
ARG LOCALIZATION="${LOCALE_STRING}.${ENCODING}"
11+
COPY . .
1212

13-
ARG OH_MY_ZSH_THEME="bira"
13+
RUN npm install
1414

15-
RUN apt update && apt -y upgrade && \
16-
apt -y install \
17-
locales \
18-
git \
19-
curl \
20-
inotify-tools \
21-
zsh && \
22-
23-
echo "${LOCALIZATION} ${ENCODING}" > /etc/locale.gen && \
24-
locale-gen "${LOCALIZATION}" && \
25-
26-
# useradd -m -u 1000 -s /usr/bin/zsh "${CONTAINER_USER}" && \
27-
28-
bash -c "$(curl -fsSL https://raw.githubusercontent.com/robbyrussell/oh-my-zsh/master/tools/install.sh)" && \
29-
30-
cp -v /root/.zshrc /home/"${CONTAINER_USER}"/.zshrc && \
31-
cp -rv /root/.oh-my-zsh /home/"${CONTAINER_USER}"/.oh-my-zsh && \
32-
sed -i "s/\/root/\/home\/${CONTAINER_USER}/g" /home/"${CONTAINER_USER}"/.zshrc && \
33-
sed -i s/ZSH_THEME=\"robbyrussell\"/ZSH_THEME=\"${OH_MY_ZSH_THEME}\"/g /home/${CONTAINER_USER}/.zshrc && \
34-
mkdir /home/"${CONTAINER_USER}"/workspace && \
35-
chown -R "${CONTAINER_USER}":"${CONTAINER_USER}" /home/"${CONTAINER_USER}"
36-
37-
USER ${CONTAINER_USER}
38-
39-
ENV USER ${CONTAINER_USER}
40-
ENV LANG "${LOCALIZATION}"
41-
ENV LANGUAGE "${LOCALE_STRING}:${LANGUAGE_CODE}"
42-
ENV PATH=/home/${CONTAINER_USER}/.local/bin:${PATH}
43-
ENV LC_ALL "${LOCALIZATION}"
44-
45-
WORKDIR /home/${CONTAINER_USER}/workspace
46-
47-
CMD ["zsh"]
15+
# Provide APP_START_CMD via --env-file.
16+
CMD ["bash", "scripts/build.sh"]

0 commit comments

Comments
 (0)