@xeplr/base-apis 2.0.3 → 2.0.5

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/README.md CHANGED
@@ -114,18 +114,21 @@ createApp(3000, 'myapp', {
114
114
 
115
115
  Why on by default: a service that forgot to configure auth used to boot clean, pass every health check, and serve every route to anybody — nothing distinguished it from a working one.
116
116
 
117
- `remoteAuth` sends the caller's `Authorization: Bearer …` to `url + endpoint` (`/auth/api/me`) and puts the answer's `user` and `access` on `req.user` / `req.access`.
117
+ `remoteAuth` sends the caller's `Authorization: Bearer …` **and the request it is serving** (`x-verify-method`, `x-verify-path`) to `url + endpoint` (`/auth/api/verify`). Auth answers who the caller is and whether they may call that API, so **every request is checked for access, not only for a signed-in token**; the answer's `user` and `access` go on `req.user` / `req.access`. See `@xeplr/auth`'s "Every request is checked".
118
118
 
119
119
  | situation | response |
120
120
  |---|---|
121
121
  | path starts with a `publicPaths` entry | skipped |
122
122
  | no `Bearer` token | 401 `No token provided` |
123
- | auth answers 401 / 403 | same status, `Invalid or expired token` |
123
+ | auth answers `403` with `code` `DISABLED`, `HIDDEN` or `UNLICENSED` | 403 with that `code`: signed in, but not allowed this API (`UNLICENSED`: its module is not in the caller's licence) |
124
+ | auth answers 401 / other 403 | same status, `Invalid or expired token` |
125
+ | auth answers 200 **without** `state: 'enabled'` (an older auth, or an `endpoint` such as `/me` that only says who the caller is) | **503**: "did not say no" is not "said yes" |
124
126
  | auth unreachable, times out (`timeoutMs`, 5000), answers anything else, or answers non-JSON | **503 `Authentication service unavailable` — never let through** |
125
127
 
126
128
  - **Validated over HTTP, not in-process** — so a service needs no `@xeplr/auth` dependency (it would be a cycle), no JWT secret and no Redis. The cost is a hop per request.
127
- - **`cacheSeconds` (default 5)** caches a validated token per process; a revoked token keeps working that long here. `0` checks every request.
128
- - **`X-New-Token` is forwarded** (with `Access-Control-Expose-Headers`) when the auth service slides a token, and that answer is **not cached** — otherwise the next requests would be answered from memory without telling the client to switch, and it would be logged out when the window closed.
129
+ - **A burst makes one call.** Requests with the same token, method and path that arrive while a check is on its way share its answer: ten at once, one call to auth. This holds with `cacheSeconds: 0` too.
130
+ - **`cacheSeconds` (default 5)** caches each answer (allowed or refused) per token, method and path, per process; a revoked token or a changed mapping takes that long to reach here. `0` checks every request.
131
+ - **`X-New-Token` is forwarded** (with `Access-Control-Expose-Headers`) when the auth service slides a token, to every request in the burst that shared the check, so none is rejected because the token changed. That answer is **not cached** — otherwise the next requests would be answered from memory without telling the client to switch, and it would be logged out when the window closed.
129
132
 
130
133
  ### Uploads
131
134
 
package/lib/remoteAuth.js CHANGED
@@ -18,7 +18,10 @@
18
18
  // everything — deliberately, because the alternative to "auth is down so
19
19
  // nothing serves" is "auth is down so everything serves unauthenticated".
20
20
 
21
- var DEFAULT_ENDPOINT = '/auth/api/me';
21
+ // Who the caller is AND whether they may call the API being served, in one
22
+ // answer. Every request asks both: there is no identity-only check, so an app
23
+ // cannot forget to ask about access. See @xeplr/auth's GET /verify.
24
+ var DEFAULT_ENDPOINT = '/auth/api/verify';
22
25
 
23
26
  // Five seconds. Long enough that a busy endpoint stops making a round trip per
24
27
  // request; short enough that a logout takes effect before anyone notices.
@@ -31,6 +34,11 @@ var DEFAULT_CACHE_SECONDS = 5;
31
34
  // closed after 5s is bad; failing closed after forever is an outage.
32
35
  var DEFAULT_TIMEOUT_MS = 5000;
33
36
 
37
+ // The 403 codes auth gives for a signed-in caller this API is refused to. Any
38
+ // other 403 is a token problem. UNLICENSED: the API's module is not in the
39
+ // caller's licence (@xeplr/auth's licence.use).
40
+ var REFUSALS = ['DISABLED', 'HIDDEN', 'UNLICENSED'];
41
+
34
42
  /**
35
43
  * @param {object} config
36
44
  * @param {string} config.url - base URL of the auth service, e.g.
@@ -40,7 +48,9 @@ var DEFAULT_TIMEOUT_MS = 5000;
40
48
  * @param {string[]} [config.publicPaths] - path PREFIXES that skip the gate.
41
49
  * @param {number} [config.cacheSeconds=5] - per-process cache of validated
42
50
  * tokens. 0 disables.
43
- * @param {string} [config.endpoint='/auth/api/me'] - the validation route.
51
+ * @param {string} [config.endpoint='/auth/api/verify'] - the validation route.
52
+ * It must answer the access state; one that does not (an older auth, or
53
+ * /me) is refused, never taken as "allowed".
44
54
  * @param {number} [config.timeoutMs=5000]
45
55
  * @returns {Function} express middleware
46
56
  */
@@ -56,11 +66,18 @@ function remoteAuth(config) {
56
66
  var timeoutMs = config.timeoutMs || DEFAULT_TIMEOUT_MS;
57
67
  var cacheSeconds = config.cacheSeconds == null ? DEFAULT_CACHE_SECONDS : config.cacheSeconds;
58
68
 
59
- // token → { expires, user, access }. Per-process and deliberately not
69
+ // token + request → { expires, outcome }. Per-process and deliberately not
60
70
  // shared: a cache in Redis would put back the dependency this exists to
61
- // remove, to save a hop it already saves.
71
+ // remove, to save a hop it already saves. Keyed by the request too, because
72
+ // the answer is about one API, not just about the token.
62
73
  var cache = new Map();
63
74
 
75
+ // The same key while its answer is on the way → the same promise. A burst
76
+ // (a dashboard firing ten widget requests at once) makes ONE call to auth,
77
+ // and all ten get its answer: the same user, the same state, and the same
78
+ // replacement token if it slid.
79
+ var pending = new Map();
80
+
64
81
  if (cacheSeconds > 0) {
65
82
  // Bounded growth. Without this a process that sees many distinct tokens
66
83
  // (every user, every rotation) keeps every one of them forever.
@@ -81,28 +98,12 @@ function remoteAuth(config) {
81
98
  return false;
82
99
  }
83
100
 
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
-
101
+ // One call to auth → an outcome every waiter can apply.
102
+ async function ask(header, method, path) {
102
103
  var response;
103
104
  try {
104
105
  response = await fetch(url + endpoint, {
105
- headers: { authorization: header },
106
+ headers: { authorization: header, 'x-verify-method': method, 'x-verify-path': path },
106
107
  signal: AbortSignal.timeout(timeoutMs)
107
108
  });
108
109
  } catch (err) {
@@ -110,34 +111,9 @@ function remoteAuth(config) {
110
111
  // establish who this is — and "cannot establish" must never resolve to
111
112
  // "let them in". Failing open here would turn one service's outage into
112
113
  // 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' });
114
+ return { kind: 'unavailable', why: 'auth unreachable: ' + err.message };
115
115
  }
116
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
117
  // SLIDING REFRESH, FORWARDED.
142
118
  //
143
119
  // With a tolerance configured, the auth service serves an expired-but-
@@ -150,27 +126,96 @@ function remoteAuth(config) {
150
126
  // tolerance window closes, and then the user is logged out mid-session
151
127
  // with nothing in any log to connect the two.
152
128
  var newToken = response.headers.get('x-new-token');
153
- if (newToken) {
154
- res.setHeader('X-New-Token', newToken);
129
+
130
+ var body = null;
131
+ try { body = await response.json(); } catch (err) { body = null; }
132
+
133
+ if (response.status === 403 && body && REFUSALS.indexOf(body.code) !== -1) {
134
+ // Signed in, but not allowed this API. The code says which, so a UI can
135
+ // grey a disabled item out rather than treat it as missing, and tell
136
+ // "not in your licence" from "not for your role".
137
+ return { kind: 'refused', newToken: newToken, body: { error: body.error || 'Access denied', code: body.code } };
138
+ }
139
+
140
+ if (!response.ok) {
141
+ // The auth service's own answer, passed through unchanged. A 401 is a
142
+ // bad token; anything else is the auth service having a problem, and
143
+ // relabelling that as 401 would send the caller looking at their token.
144
+ if (response.status === 401 || response.status === 403) {
145
+ return { kind: 'invalid', status: response.status };
146
+ }
147
+ return { kind: 'unavailable', why: 'auth returned ' + response.status };
148
+ }
149
+
150
+ // 200 but no access answer: this auth does not check access (an older
151
+ // version, or an endpoint that only says who the caller is). Refused,
152
+ // because "did not say no" is not "said yes".
153
+ if (!body || body.state !== 'enabled') {
154
+ return { kind: 'unavailable', why: 'auth answered without an access state; update @xeplr/auth' };
155
+ }
156
+
157
+ return { kind: 'ok', newToken: newToken, user: body.user, access: body.access };
158
+ }
159
+
160
+ function apply(outcome, req, res, next) {
161
+ if (outcome.newToken) {
162
+ res.setHeader('X-New-Token', outcome.newToken);
155
163
  // Without this a browser cannot READ the header, so forwarding it would
156
164
  // be invisible anyway — same pair @xeplr/auth sets locally.
157
165
  res.setHeader('Access-Control-Expose-Headers', 'X-New-Token');
158
166
  }
167
+ if (outcome.kind === 'ok') {
168
+ req.user = outcome.user;
169
+ // Permissions arrive with identity, in the same answer — so a route that
170
+ // needs to know what this caller may do does not make a second call for
171
+ // it.
172
+ req.access = outcome.access;
173
+ return next();
174
+ }
175
+ if (outcome.kind === 'refused') return res.status(403).json(outcome.body);
176
+ if (outcome.kind === 'invalid') return res.status(outcome.status).json({ error: 'Invalid or expired token' });
177
+ if (req.log) req.log.error(outcome.why);
178
+ return res.status(503).json({ error: 'Authentication service unavailable' });
179
+ }
159
180
 
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
- });
181
+ return async function remoteAuthMiddleware(req, res, next) {
182
+ if (isPublic(req.path)) return next();
183
+
184
+ var header = req.headers.authorization;
185
+ if (!header || header.indexOf('Bearer ') !== 0) {
186
+ return res.status(401).json({ error: 'No token provided' });
171
187
  }
188
+ var token = header.slice('Bearer '.length);
189
+ var method = req.method;
190
+ var path = (req.baseUrl || '') + req.path;
191
+ var key = token + '\n' + method + ' ' + path;
172
192
 
173
- next();
193
+ if (cacheSeconds > 0) {
194
+ var hit = cache.get(key);
195
+ if (hit && hit.expires > Date.now()) return apply(hit.outcome, req, res, next);
196
+ }
197
+
198
+ var inFlight = pending.get(key);
199
+ if (!inFlight) {
200
+ inFlight = ask(header, method, path);
201
+ pending.set(key, inFlight);
202
+ inFlight.then(function (outcome) {
203
+ pending.delete(key);
204
+ // NOT CACHED WHEN THE TOKEN SLID. The caller is being told to switch
205
+ // to a replacement, and caching the old one would answer its next few
206
+ // requests from memory — without the header telling it to switch. A
207
+ // client that missed the first one would never be told again, and
208
+ // would be cut off when the window closed.
209
+ //
210
+ // Only a real answer is cached: an outage or a bad token is asked
211
+ // again next time.
212
+ var answer = outcome.kind === 'ok' || outcome.kind === 'refused';
213
+ if (cacheSeconds > 0 && answer && !outcome.newToken) {
214
+ cache.set(key, { outcome: outcome, expires: Date.now() + cacheSeconds * 1000 });
215
+ }
216
+ });
217
+ }
218
+ apply(await inFlight, req, res, next);
174
219
  };
175
220
  }
176
221
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xeplr/base-apis",
3
- "version": "2.0.3",
3
+ "version": "2.0.5",
4
4
  "description": "Base API bootstrapper — Express and plain HTTP server factories with middleware injection",
5
5
  "main": "index.js",
6
6
  "bin": {