@xeplr/base-apis 1.0.5 → 2.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/express.js CHANGED
@@ -15,7 +15,16 @@ var logs = require('@xeplr/logs');
15
15
  * @param {object} [options] - Configuration options
16
16
  * @param {string[]} [options.requiredEnv] - Mandatory env var names; missing ones abort startup
17
17
  * @param {object} [options.routes] - Route map: { '/path': router }
18
- * @param {Function[]} [options.middleware] - Middleware to apply before routes (e.g. authMiddleware)
18
+ * @param {object|false} [options.auth] - THE GATE, ON BY DEFAULT.
19
+ * Omit it and every route requires a valid token, validated by asking the
20
+ * auth service at AUTH_URL. Boot fails if that address is not set, rather
21
+ * than serving unauthenticated.
22
+ * { url } auth service base URL (default: process.env.AUTH_URL)
23
+ * { publicPaths } path prefixes that skip the gate, e.g. ['/public/']
24
+ * { cacheSeconds } per-process cache of validated tokens (default 5, 0 off)
25
+ * { middleware } supply your own gate instead of the HTTP one
26
+ * false this service is public ON PURPOSE
27
+ * @param {Function[]} [options.middleware] - Middleware to apply before routes (runs AFTER the auth gate)
19
28
  * @param {object} [options.views] - { engine: 'pug', dir: '/abs/path' }
20
29
  * @param {string} [options.staticDir] - Path to static files directory
21
30
  * @param {string} [options.corsOptions] - Custom CORS options (default: allow all)
@@ -84,6 +93,50 @@ function createApp(port, appName, options) {
84
93
  app.use(serveRoute, express.static(destination));
85
94
  }
86
95
 
96
+ // ── Auth gate ──
97
+ //
98
+ // ON BY DEFAULT. A service that forgets to configure auth must not answer
99
+ // as though it has none — that failure is silent, boots clean, passes every
100
+ // health check, and is indistinguishable from a working service until
101
+ // somebody notices the whole API is public.
102
+ //
103
+ // Every app answers the question, one of four ways:
104
+ // auth omitted → gated, via AUTH_URL from the environment
105
+ // auth: { url, ... } → gated, configured here
106
+ // auth: { middleware: fn } → gated by a gate you supply. This is how
107
+ // @xeplr/auth's own server gates itself: it
108
+ // cannot ask itself over HTTP.
109
+ // auth: false → public, ON PURPOSE and visible in the diff
110
+ //
111
+ // BEFORE options.middleware, so anything injected below — mtMiddleware and
112
+ // friends — runs with req.user already established.
113
+ if (options.auth !== false) {
114
+ var authCfg = (options.auth === true || options.auth == null) ? {} : options.auth;
115
+
116
+ if (authCfg.middleware) {
117
+ app.use(authCfg.middleware);
118
+ } else {
119
+ var authUrl = authCfg.url || process.env.AUTH_URL;
120
+ if (!authUrl) {
121
+ // Refuse, naming the variable. The alternative is guessing an address
122
+ // — and a wrong-but-present one either 503s everything or points at
123
+ // something that answers 200 to anything.
124
+ throw new Error(
125
+ '[' + appName + '] createApp: auth is on by default and needs the auth service address.\n' +
126
+ ' Set AUTH_URL (e.g. http://localhost:19141), or pass options.auth = { url },\n' +
127
+ ' or pass options.auth = false if this service is genuinely public.'
128
+ );
129
+ }
130
+ app.use(require('./lib/remoteAuth')({
131
+ url: authUrl,
132
+ publicPaths: authCfg.publicPaths,
133
+ cacheSeconds: authCfg.cacheSeconds,
134
+ endpoint: authCfg.endpoint,
135
+ timeoutMs: authCfg.timeoutMs
136
+ }));
137
+ }
138
+ }
139
+
87
140
  // ── Injected middleware (auth, etc.) ──
88
141
  if (options.middleware) {
89
142
  options.middleware.forEach(function(mw) { app.use(mw); });
package/index.js CHANGED
@@ -3,6 +3,7 @@ module.exports = {
3
3
  createHttpServer: require('./http'),
4
4
  checkEnv: require('./lib/checkEnv'),
5
5
  genericRoute: require('./lib/genericRoute'),
6
+ remoteAuth: require('./lib/remoteAuth'),
6
7
  genericController: require('./lib/genericController'),
7
8
  uploadRoute: require('./lib/uploadRoute'),
8
9
  runCluster: require('./lib/cluster').runCluster,
@@ -0,0 +1,177 @@
1
+ // THE GATE EVERY SERVICE GETS WITHOUT ASKING FOR IT.
2
+ //
3
+ // Validates the caller's token by asking the auth service, rather than by
4
+ // verifying the signature in-process. That is the whole design decision, and
5
+ // it buys three things:
6
+ //
7
+ // • No dependency on @xeplr/auth. It cannot be one — @xeplr/auth requires
8
+ // THIS package to build its own server, so requiring it back would be a
9
+ // cycle. An HTTP call needs nothing but a URL, which is what lets
10
+ // createApp gate by default at all.
11
+ // • No AUTH_JWT_SECRET in every app. A service that only needs to READ a
12
+ // token no longer has to hold the secret used to MINT one, so rotating it
13
+ // is one edit instead of one per service.
14
+ // • No Redis in every app. Session liveness is the auth service's business.
15
+ //
16
+ // The cost is a network hop per request, which `cacheSeconds` exists to blunt
17
+ // (see below). The auth service also becomes a hard runtime dependency for
18
+ // everything — deliberately, because the alternative to "auth is down so
19
+ // nothing serves" is "auth is down so everything serves unauthenticated".
20
+
21
+ var DEFAULT_ENDPOINT = '/auth/api/me';
22
+
23
+ // Five seconds. Long enough that a busy endpoint stops making a round trip per
24
+ // request; short enough that a logout takes effect before anyone notices.
25
+ //
26
+ // This is the one real trade in the design: a revoked token keeps working for
27
+ // up to this long in THIS process. Set 0 to check every time.
28
+ var DEFAULT_CACHE_SECONDS = 5;
29
+
30
+ // A request that hangs must not hang the caller's request with it. Failing
31
+ // closed after 5s is bad; failing closed after forever is an outage.
32
+ var DEFAULT_TIMEOUT_MS = 5000;
33
+
34
+ /**
35
+ * @param {object} config
36
+ * @param {string} config.url - base URL of the auth service, e.g.
37
+ * http://localhost:19141. NEVER defaulted: a wrong-but-present address
38
+ * means every request 503s, and a guessed one is worse — it could be a
39
+ * service that answers 200 to anything.
40
+ * @param {string[]} [config.publicPaths] - path PREFIXES that skip the gate.
41
+ * @param {number} [config.cacheSeconds=5] - per-process cache of validated
42
+ * tokens. 0 disables.
43
+ * @param {string} [config.endpoint='/auth/api/me'] - the validation route.
44
+ * @param {number} [config.timeoutMs=5000]
45
+ * @returns {Function} express middleware
46
+ */
47
+ function remoteAuth(config) {
48
+ config = config || {};
49
+ if (!config.url) {
50
+ throw new Error('remoteAuth: config.url is required — the base URL of the auth service');
51
+ }
52
+
53
+ var url = String(config.url).replace(/\/+$/, '');
54
+ var endpoint = config.endpoint || DEFAULT_ENDPOINT;
55
+ var publicPaths = config.publicPaths || [];
56
+ var timeoutMs = config.timeoutMs || DEFAULT_TIMEOUT_MS;
57
+ var cacheSeconds = config.cacheSeconds == null ? DEFAULT_CACHE_SECONDS : config.cacheSeconds;
58
+
59
+ // token → { expires, user, access }. Per-process and deliberately not
60
+ // shared: a cache in Redis would put back the dependency this exists to
61
+ // remove, to save a hop it already saves.
62
+ var cache = new Map();
63
+
64
+ if (cacheSeconds > 0) {
65
+ // Bounded growth. Without this a process that sees many distinct tokens
66
+ // (every user, every rotation) keeps every one of them forever.
67
+ var sweep = setInterval(function () {
68
+ var now = Date.now();
69
+ cache.forEach(function (entry, token) {
70
+ if (entry.expires <= now) cache.delete(token);
71
+ });
72
+ }, Math.max(cacheSeconds, 1) * 1000);
73
+ // House rule: a timer must never be the reason a process stays alive.
74
+ if (sweep.unref) sweep.unref();
75
+ }
76
+
77
+ function isPublic(path) {
78
+ for (var i = 0; i < publicPaths.length; i++) {
79
+ if (path === publicPaths[i] || path.indexOf(publicPaths[i]) === 0) return true;
80
+ }
81
+ return false;
82
+ }
83
+
84
+ return async function remoteAuthMiddleware(req, res, next) {
85
+ if (isPublic(req.path)) return next();
86
+
87
+ var header = req.headers.authorization;
88
+ if (!header || header.indexOf('Bearer ') !== 0) {
89
+ return res.status(401).json({ error: 'No token provided' });
90
+ }
91
+ var token = header.slice('Bearer '.length);
92
+
93
+ if (cacheSeconds > 0) {
94
+ var hit = cache.get(token);
95
+ if (hit && hit.expires > Date.now()) {
96
+ req.user = hit.user;
97
+ req.access = hit.access;
98
+ return next();
99
+ }
100
+ }
101
+
102
+ var response;
103
+ try {
104
+ response = await fetch(url + endpoint, {
105
+ headers: { authorization: header },
106
+ signal: AbortSignal.timeout(timeoutMs)
107
+ });
108
+ } catch (err) {
109
+ // FAIL CLOSED, ALWAYS. An unreachable auth service means we cannot
110
+ // establish who this is — and "cannot establish" must never resolve to
111
+ // "let them in". Failing open here would turn one service's outage into
112
+ // an authentication bypass across every app that trusts this gate.
113
+ if (req.log) req.log.error('auth unreachable: ' + err.message);
114
+ return res.status(503).json({ error: 'Authentication service unavailable' });
115
+ }
116
+
117
+ if (!response.ok) {
118
+ // The auth service's own answer, passed through unchanged. A 401 is a
119
+ // bad token; anything else is the auth service having a problem, and
120
+ // relabelling that as 401 would send the caller looking at their token.
121
+ if (response.status === 401 || response.status === 403) {
122
+ return res.status(response.status).json({ error: 'Invalid or expired token' });
123
+ }
124
+ if (req.log) req.log.error('auth returned ' + response.status);
125
+ return res.status(503).json({ error: 'Authentication service unavailable' });
126
+ }
127
+
128
+ var body;
129
+ try {
130
+ body = await response.json();
131
+ } catch (err) {
132
+ return res.status(503).json({ error: 'Authentication service unavailable' });
133
+ }
134
+
135
+ req.user = body.user;
136
+ // Permissions arrive with identity, in the same answer — so a route that
137
+ // needs to know what this caller may do does not make a second call for
138
+ // it.
139
+ req.access = body.access;
140
+
141
+ // SLIDING REFRESH, FORWARDED.
142
+ //
143
+ // With a tolerance configured, the auth service serves an expired-but-
144
+ // recent token AND mints a replacement, handing it back as X-New-Token.
145
+ // Validating over HTTP puts that header on the auth service's reply to
146
+ // US, where the browser will never see it — so it has to be copied onto
147
+ // the response the caller actually receives.
148
+ //
149
+ // Dropping it does not fail loudly: every request keeps working until the
150
+ // tolerance window closes, and then the user is logged out mid-session
151
+ // with nothing in any log to connect the two.
152
+ var newToken = response.headers.get('x-new-token');
153
+ if (newToken) {
154
+ res.setHeader('X-New-Token', newToken);
155
+ // Without this a browser cannot READ the header, so forwarding it would
156
+ // be invisible anyway — same pair @xeplr/auth sets locally.
157
+ res.setHeader('Access-Control-Expose-Headers', 'X-New-Token');
158
+ }
159
+
160
+ // NOT CACHED WHEN THE TOKEN SLID. The caller is being told to switch to a
161
+ // replacement, and caching the old one would answer its next few requests
162
+ // from memory — without the header telling it to switch. A client that
163
+ // missed the first one would never be told again, and would be cut off
164
+ // when the window closed.
165
+ if (cacheSeconds > 0 && !newToken) {
166
+ cache.set(token, {
167
+ user: body.user,
168
+ access: body.access,
169
+ expires: Date.now() + cacheSeconds * 1000
170
+ });
171
+ }
172
+
173
+ next();
174
+ };
175
+ }
176
+
177
+ module.exports = remoteAuth;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xeplr/base-apis",
3
- "version": "1.0.5",
3
+ "version": "2.0.1",
4
4
  "description": "Base API bootstrapper — Express and plain HTTP server factories with middleware injection",
5
5
  "main": "index.js",
6
6
  "bin": {