@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 +54 -1
- package/index.js +1 -0
- package/lib/remoteAuth.js +177 -0
- package/package.json +1 -1
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 {
|
|
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;
|