@xeplr/base-apis 2.0.2 → 2.0.4

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.
Files changed (3) hide show
  1. package/README.md +272 -25
  2. package/lib/remoteAuth.js +102 -63
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -1,35 +1,282 @@
1
1
  # @xeplr/base-apis
2
2
 
3
- The service layer for a xeplr app: `createApp` wires an Express service, and
4
- `genericRoute` gives any model a full CRUD API. Composes with `@xeplr/db` and
5
- `@xeplr/auth`.
3
+ **The service layer for a xeplr app.** `createApp` builds an Express service that is **authenticated by default**, `genericRoute` gives a model hierarchy a full CRUD API, and the rest — env checks, uploads, server-sent events, clustering — is what every service otherwise hand-rolls.
6
4
 
7
- ## Setup
5
+ ```js
6
+ var { createApp, genericRoute } = require('@xeplr/base-apis')
8
7
 
9
- 1. Install `@xeplr/base-apis` (add `@xeplr/db`, `@xeplr/auth`, `dotenv`).
10
- 2. Create a file env.required.js
11
- 3. For anything
12
- 3. Each API is by default guarded through Auth System. But you will need following variables in your env file for AUTH: AUTH_DB_NAME, AUTH_DB_CONNECTION_INFO_ENCRYPTED, XEPLR_AUTH_MIGRATIONS, AUTH_PORT.
13
- 4. The XEPLR_AUTH_MIGRATIONS is y
8
+ createApp(process.env.PORT, 'myapp', {
9
+ requiredEnv: require('./env.required'),
10
+ routes: { '/api/departments': genericRoute({ key: 'department', model: Department }) }
11
+ })
12
+ ```
13
+
14
+ ## How the pieces fit
15
+
16
+ | what | where |
17
+ |---|---|
18
+ | An Express service: logging, CORS, body parsing, auth gate, routes, 404, errors | `createApp(port, appName, options)` |
19
+ | Who the caller is | the **auth gate** — asks `@xeplr/auth` at `AUTH_URL`; sets `req.user` and `req.access` |
20
+ | List / get / save / delete for a model and its children | `genericRoute(hierarchy)` |
21
+ | Refusing to start without the settings a service needs | `checkEnv` / `requiredEnv` / `xeplr-check-env` |
22
+ | Server → browser push | `sse` |
23
+ | Several processes | `runCluster`, `broadcast` |
24
+ | A server without Express | `createHttpServer.init(port, name, handler)` |
25
+
26
+ Models, tenancy and connections come from [`@xeplr/db`](https://www.npmjs.com/package/@xeplr/db).
27
+
28
+ ## Install
29
+
30
+ ```sh
31
+ npm i @xeplr/base-apis express
32
+ ```
33
+
34
+ Peers: `express` (4 or 5), `multer` (optional — only for uploads). Depends on `@xeplr/logs` and `@xeplr/utils`.
35
+
36
+ ## Quick start
37
+
38
+ ```js
39
+ // env.required.js
40
+ module.exports = ['XEPLR_DB_CONNECTION', 'ENCRYPTION_KEY', 'DB_API'].concat(require('@xeplr/base-apis').gateRequiredEnv)
41
+ ```
42
+
43
+ ```js
44
+ var { createApp, genericRoute } = require('@xeplr/base-apis')
45
+ var { getConnection, registerMTs, mtMiddleware, BaseModel } = require('@xeplr/db')
46
+
47
+ registerMTs({ l1: { name: 'companyId', header: 'x-company-id' } })
48
+ await getConnection(process.env.DB_API, process.env.XEPLR_DB_CONNECTION)
49
+
50
+ class Department extends BaseModel { static get tableName() { return 'departments' } }
51
+
52
+ createApp(3000, 'myapp', {
53
+ requiredEnv: require('./env.required'),
54
+ auth: { publicPaths: ['/public/'] }, // omitted = every route gated
55
+ middleware: [mtMiddleware()], // runs after the gate, so req.user is set
56
+ routes: { '/api/departments': genericRoute({ key: 'department', model: Department }) }
57
+ })
58
+ ```
59
+
60
+ ## Exports
61
+
62
+ | export | |
63
+ |---|---|
64
+ | `createApp(port, appName, options)` | Express service → `{ app, server }` |
65
+ | `genericRoute(hierarchy, { auth })` | CRUD router |
66
+ | `genericController` | `{ list, getById, save, deleteByIds }` — the handlers behind `genericRoute` |
67
+ | `remoteAuth(config)` | the default gate, as middleware |
68
+ | `gateRequiredEnv` | `['AUTH_URL']` |
69
+ | `checkEnv(names, options)` | missing-variable check |
70
+ | `uploadRoute(options)` | a standalone "receive a file, return its path" router |
71
+ | `sse` | `{ handler, createHandler, publish, defaultAuthorizeTopics }` |
72
+ | `runCluster(options)`, `broadcast(msg)`, `isPrimary()`, `isWorker()` | Node cluster helpers |
73
+ | `createHttpServer` | `{ init(port, appName, handler) }` — a plain `http.Server` |
14
74
 
15
- Example:
75
+ ## createApp
16
76
 
17
- ```bash
18
- var { resolveConfig, getConnection, bindModels } = require('@xeplr/db');
77
+ `createApp(port, appName, options)` listens on `port` (a number or a named pipe) and returns `{ app, server }`. `EADDRINUSE` / `EACCES` print a message and exit.
19
78
 
20
- async function setup() {
21
- var dbName = process.env.DB_API || 'xeplr_bi';
22
- await resolveConfig('api', process.env.BI_CONNECTION); // one shared secret
23
- var connection = getConnection(dbName, { connectionName: 'api' });
24
- bindModels(connection);
25
- return connection;
26
- }
79
+ | option | |
80
+ |---|---|
81
+ | `requiredEnv` | variable names; any missing → message and `process.exit(1)` before anything starts |
82
+ | `auth` | the gate — see below. **On when omitted** |
83
+ | `middleware` | array, mounted after the gate |
84
+ | `routes` | `{ '/path': router }` |
85
+ | `log` | `@xeplr/logs` settings (`logDir` `'./logs'`, `fatalThreshold`, `emailThreshold`, `emailTo`, `emailService`), or `false` |
86
+ | `corsOptions` | passed to `cors` (default: allow all) |
87
+ | `staticDir` | `express.static` directory |
88
+ | `views` | `{ engine: 'pug', dir }` — also switches 404 and errors to rendered pages |
89
+ | `upload` | file upload — see below |
90
+ | `sse` | `true` for `GET /events`, or `{ path }` |
91
+ | `errorHandler` | replaces the default `{ error: message }` JSON handler |
27
92
 
28
- module.exports = setup();
93
+ ### Order of the middleware
94
+
95
+ 1. request logger (`@xeplr/logs` — sets `req.log`), unless `log: false`
96
+ 2. view engine, `cors`, `express.json`, `express.urlencoded`, `cookie-parser`
97
+ 3. `staticDir`
98
+ 4. uploaded files served at `upload.serveRoute` — **before the gate, so public**
99
+ 5. **the auth gate**
100
+ 6. `options.middleware`
101
+ 7. `POST upload.route`
102
+ 8. SSE endpoint
103
+ 9. `options.routes`
104
+ 10. 404, then the error handler
105
+
106
+ ### The auth gate
107
+
108
+ | `auth` | result |
109
+ |---|---|
110
+ | omitted | gated through `remoteAuth` with `AUTH_URL`. **No `AUTH_URL` → `createApp` throws** |
111
+ | `{ url, publicPaths, cacheSeconds, endpoint, timeoutMs }` | gated, configured here |
112
+ | `{ middleware: fn }` | your gate instead — how `@xeplr/auth`'s own server gates itself (it cannot ask itself over HTTP) |
113
+ | `false` | public, on purpose and visible in the diff |
114
+
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
+
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
+
119
+ | situation | response |
120
+ |---|---|
121
+ | path starts with a `publicPaths` entry | skipped |
122
+ | no `Bearer` token | 401 `No token provided` |
123
+ | auth answers `403` with `code` `DISABLED` or `HIDDEN` | 403 with that `code`: signed in, but not allowed this API |
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" |
126
+ | auth unreachable, times out (`timeoutMs`, 5000), answers anything else, or answers non-JSON | **503 `Authentication service unavailable` — never let through** |
127
+
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.
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.
132
+
133
+ ### Uploads
134
+
135
+ ```js
136
+ createApp(port, 'myapp', { upload: { destination: './uploads', allowedTypes: ['image/*'], maxSize: 2e6 } })
29
137
  ```
30
138
 
31
- 4. Define models (extend `BaseModel`).
32
- 5. Expose CRUD routes with `genericRoute`, one per resource.
33
- 6. Create the service with `createApp` — mount routes, add auth middleware.
34
- 7. Write migrations (schema + data) and run them.
35
- 8. Start the app.
139
+ | option | default |
140
+ |---|---|
141
+ | `route` | `/internal/upload` — `POST`, multipart, behind the gate |
142
+ | `fieldName` | `file` |
143
+ | `maxFiles` | `10` |
144
+ | `destination` | `UPLOAD_DIR`, else `./uploads` |
145
+ | `allowedTypes` | `['*']` |
146
+ | `maxSize` | `5242880` (5 MB) |
147
+ | `serveRoute` | `/uploads` — static, **before the gate** |
148
+ | `auth` | a function passed to `@xeplr/utils`' `FileUploader` |
149
+
150
+ Returns `{ filename, originalName, size, mimetype }`, or an array of them for several files; 400 when none arrived. Files are stored under a generated id plus the original extension.
151
+
152
+ `uploadRoute({ destination, maxSize, allowedTypes, fieldName, auth })` is the same as a router of its own: `POST /` (single file) → `{ filePath, fileName, originalName, size, mimetype }`.
153
+
154
+ ## genericRoute
155
+
156
+ ```js
157
+ genericRoute({
158
+ key: 'department',
159
+ model: Department,
160
+ children: [
161
+ { key: 'employees', model: Employee, foreignKey: 'departmentId',
162
+ children: [{ key: 'monthlySalaries', model: MonthlySalary, foreignKey: 'employeeId' }] }
163
+ ]
164
+ }, { auth: { list: gate, getById: gate, save: [gate, adminOnly], delete: [gate, adminOnly] } })
165
+ ```
166
+
167
+ `children[].key` is the Objection relation name; it becomes the eager graph (`employees.monthlySalaries`). `auth` is a middleware, an array, or per operation; omitted means no extra middleware (the `createApp` gate still applies).
168
+
169
+ | route | does |
170
+ |---|---|
171
+ | `GET /` | list with children; `?page=1&limit=50` → `pagination: { page, limit, total, totalPages }`; `?limit=0` returns everything |
172
+ | `GET /:id` | one record with children; 404 if none |
173
+ | `POST /save` | a changeset array, **in one transaction** → `updatedIds` |
174
+ | `POST /delete` | `{ ids }` → `isActive = false` |
175
+
176
+ Queries go through `Model.query()`, so a `BaseModel` gets its active, tenant and application filters.
177
+
178
+ ### The changeset
179
+
180
+ ```json
181
+ [
182
+ { "id": "a1b2", "name": "Engineering", "employees": [
183
+ { "name": "New Hire" },
184
+ { "id": "d4e5", "role": "Senior" },
185
+ { "id": "x1y2", "deleted": true } ] },
186
+ { "name": "New Department" }
187
+ ]
188
+ ```
189
+
190
+ | entry | action |
191
+ |---|---|
192
+ | no `id` | insert with a generated id |
193
+ | `id` that exists | patch the fields given |
194
+ | `id` that does not exist | insert with that id |
195
+ | `deleted: true` | `isActive = false` |
196
+ | a key matching a child `key` | processed recursively; the child's `foreignKey` is set to the parent id |
197
+
198
+ Every query carries `.context({ user: req.user, afterCommit: [] })`. A model hook that must write to **another connection** pushes a function onto `afterCommit` instead of writing inline: the callbacks run only after the transaction commits, so a later failure in the same changeset cannot leave an orphan behind. A failing callback is logged, not returned.
199
+
200
+ ### Responses and errors
201
+
202
+ Responses use `@xeplr/utils`' shape: `{ code, message, error, dataArray, updatedIds, sessionId }` (+ `pagination`). Database errors are mapped by `err.name` (so two copies of objection still match):
203
+
204
+ | error | status |
205
+ |---|---|
206
+ | `ValidationError` | 422 |
207
+ | `UniqueViolationError`, `ForeignKeyViolationError` | 409 |
208
+ | `NotNullViolationError` (`<column> is required`), `CheckViolationError`, `DataError` | 400 |
209
+ | `NotFoundError` | 404 |
210
+ | anything else | 500 |
211
+
212
+ ## checkEnv
213
+
214
+ ```js
215
+ checkEnv(['XEPLR_DB_CONNECTION', 'AUTH_URL'], { appName: 'myapp' })
216
+ ```
217
+
218
+ Options: `appName`, `exit` (default `true` — `process.exit(1)` on failure), `throw` (throw instead), `silent` (no success line). Returns `{ ok, missing }`.
219
+
220
+ ```sh
221
+ npx xeplr-check-env # checks ./env.required.js
222
+ npx xeplr-check-env --file ./env.list.js --name myapp
223
+ ```
224
+
225
+ The same check at build time. It reads `process.env` only; load the `.env` first (e.g. `dotenv-cli`).
226
+
227
+ `gateRequiredEnv` is separate from any plain `requiredEnv` on purpose: only an app using the default gate needs `AUTH_URL`; one with `auth: false` or its own `middleware` would have to invent a value.
228
+
229
+ ## Server-sent events
230
+
231
+ ```js
232
+ var { sse } = require('@xeplr/base-apis')
233
+
234
+ app.get('/events', sse.createHandler({ validateTicket: ticketService.consumeTicket }))
235
+ sse.publish('user:42:jobs', { jobId, status })
236
+ ```
237
+
238
+ ```js
239
+ new EventSource('/events?ticket=<tk>&topics=user:me:jobs').onmessage = (e) => { const { topic, data } = JSON.parse(e.data) }
240
+ ```
241
+
242
+ | | |
243
+ |---|---|
244
+ | `createHandler({ validateTicket, authorizeTopics? })` | production handler. `?ticket` required (401), `?topics` required (400). `validateTicket(ticket)` → `{ userId, scopes }` or `null` (401); throwing → 500. No authorised topic → 403 |
245
+ | `defaultAuthorizeTopics(payload, requested)` | rewrites `user:me:` to `user:<userId>:`, keeps topics matching a scope glob (default scope `user:me:*`), and **always drops another user's `user:<id>:` topic**, whatever the scopes say |
246
+ | `handler` | **no ticket, no topic check** — what `createApp({ sse: true })` mounts. For development and private networks |
247
+ | `publish(topic, data)` | writes to local subscribers and relays to every cluster worker |
248
+
249
+ A comment line is sent every 25 s so proxies do not drop idle streams; subscriptions are removed when the connection closes. The per-topic check exists because checking the ticket on connect is easy, while trusting client-supplied topics is how one user ends up subscribed to another's.
250
+
251
+ ## Cluster
252
+
253
+ ```js
254
+ runCluster({
255
+ workers: 4, // default: CPU count
256
+ respawn: true, // default
257
+ start: function () { createApp(3000, 'myapp', { … }) },
258
+ onMessage: function (msg) { if (msg.type === 'cache-clear') clearCache() }
259
+ })
260
+
261
+ broadcast({ type: 'cache-clear' }) // from a worker: relayed to every other worker
262
+ ```
263
+
264
+ ## Environment
265
+
266
+ | variable | |
267
+ |---|---|
268
+ | `AUTH_URL` | the auth service base URL — **required** for the default gate |
269
+ | `UPLOAD_DIR` | upload destination when `upload.destination` is not given |
270
+ | `NODE_ENV` | anything but `production` puts the logger in dev mode |
271
+
272
+ ## Tests
273
+
274
+ ```sh
275
+ npm test
276
+ ```
277
+
278
+ The auth gate against a stand-in auth service (refusal to boot, 401 / 503, public paths, caching, `X-New-Token`), SSE publish and subscribe, and ticket-authorised SSE.
279
+
280
+ ## License
281
+
282
+ MIT
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.
@@ -40,7 +43,9 @@ var DEFAULT_TIMEOUT_MS = 5000;
40
43
  * @param {string[]} [config.publicPaths] - path PREFIXES that skip the gate.
41
44
  * @param {number} [config.cacheSeconds=5] - per-process cache of validated
42
45
  * tokens. 0 disables.
43
- * @param {string} [config.endpoint='/auth/api/me'] - the validation route.
46
+ * @param {string} [config.endpoint='/auth/api/verify'] - the validation route.
47
+ * It must answer the access state; one that does not (an older auth, or
48
+ * /me) is refused, never taken as "allowed".
44
49
  * @param {number} [config.timeoutMs=5000]
45
50
  * @returns {Function} express middleware
46
51
  */
@@ -56,11 +61,18 @@ function remoteAuth(config) {
56
61
  var timeoutMs = config.timeoutMs || DEFAULT_TIMEOUT_MS;
57
62
  var cacheSeconds = config.cacheSeconds == null ? DEFAULT_CACHE_SECONDS : config.cacheSeconds;
58
63
 
59
- // token → { expires, user, access }. Per-process and deliberately not
64
+ // token + request → { expires, outcome }. Per-process and deliberately not
60
65
  // shared: a cache in Redis would put back the dependency this exists to
61
- // remove, to save a hop it already saves.
66
+ // remove, to save a hop it already saves. Keyed by the request too, because
67
+ // the answer is about one API, not just about the token.
62
68
  var cache = new Map();
63
69
 
70
+ // The same key while its answer is on the way → the same promise. A burst
71
+ // (a dashboard firing ten widget requests at once) makes ONE call to auth,
72
+ // and all ten get its answer: the same user, the same state, and the same
73
+ // replacement token if it slid.
74
+ var pending = new Map();
75
+
64
76
  if (cacheSeconds > 0) {
65
77
  // Bounded growth. Without this a process that sees many distinct tokens
66
78
  // (every user, every rotation) keeps every one of them forever.
@@ -81,28 +93,12 @@ function remoteAuth(config) {
81
93
  return false;
82
94
  }
83
95
 
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
-
96
+ // One call to auth → an outcome every waiter can apply.
97
+ async function ask(header, method, path) {
102
98
  var response;
103
99
  try {
104
100
  response = await fetch(url + endpoint, {
105
- headers: { authorization: header },
101
+ headers: { authorization: header, 'x-verify-method': method, 'x-verify-path': path },
106
102
  signal: AbortSignal.timeout(timeoutMs)
107
103
  });
108
104
  } catch (err) {
@@ -110,34 +106,9 @@ function remoteAuth(config) {
110
106
  // establish who this is — and "cannot establish" must never resolve to
111
107
  // "let them in". Failing open here would turn one service's outage into
112
108
  // 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' });
109
+ return { kind: 'unavailable', why: 'auth unreachable: ' + err.message };
115
110
  }
116
111
 
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
112
  // SLIDING REFRESH, FORWARDED.
142
113
  //
143
114
  // With a tolerance configured, the auth service serves an expired-but-
@@ -150,27 +121,95 @@ function remoteAuth(config) {
150
121
  // tolerance window closes, and then the user is logged out mid-session
151
122
  // with nothing in any log to connect the two.
152
123
  var newToken = response.headers.get('x-new-token');
153
- if (newToken) {
154
- res.setHeader('X-New-Token', newToken);
124
+
125
+ var body = null;
126
+ try { body = await response.json(); } catch (err) { body = null; }
127
+
128
+ if (response.status === 403 && body && (body.code === 'DISABLED' || body.code === 'HIDDEN')) {
129
+ // Signed in, but not allowed this API. The code says which, so a UI can
130
+ // grey a disabled item out rather than treat it as missing.
131
+ return { kind: 'refused', newToken: newToken, body: { error: body.error || 'Access denied', code: body.code } };
132
+ }
133
+
134
+ if (!response.ok) {
135
+ // The auth service's own answer, passed through unchanged. A 401 is a
136
+ // bad token; anything else is the auth service having a problem, and
137
+ // relabelling that as 401 would send the caller looking at their token.
138
+ if (response.status === 401 || response.status === 403) {
139
+ return { kind: 'invalid', status: response.status };
140
+ }
141
+ return { kind: 'unavailable', why: 'auth returned ' + response.status };
142
+ }
143
+
144
+ // 200 but no access answer: this auth does not check access (an older
145
+ // version, or an endpoint that only says who the caller is). Refused,
146
+ // because "did not say no" is not "said yes".
147
+ if (!body || body.state !== 'enabled') {
148
+ return { kind: 'unavailable', why: 'auth answered without an access state; update @xeplr/auth' };
149
+ }
150
+
151
+ return { kind: 'ok', newToken: newToken, user: body.user, access: body.access };
152
+ }
153
+
154
+ function apply(outcome, req, res, next) {
155
+ if (outcome.newToken) {
156
+ res.setHeader('X-New-Token', outcome.newToken);
155
157
  // Without this a browser cannot READ the header, so forwarding it would
156
158
  // be invisible anyway — same pair @xeplr/auth sets locally.
157
159
  res.setHeader('Access-Control-Expose-Headers', 'X-New-Token');
158
160
  }
161
+ if (outcome.kind === 'ok') {
162
+ req.user = outcome.user;
163
+ // Permissions arrive with identity, in the same answer — so a route that
164
+ // needs to know what this caller may do does not make a second call for
165
+ // it.
166
+ req.access = outcome.access;
167
+ return next();
168
+ }
169
+ if (outcome.kind === 'refused') return res.status(403).json(outcome.body);
170
+ if (outcome.kind === 'invalid') return res.status(outcome.status).json({ error: 'Invalid or expired token' });
171
+ if (req.log) req.log.error(outcome.why);
172
+ return res.status(503).json({ error: 'Authentication service unavailable' });
173
+ }
159
174
 
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
- });
175
+ return async function remoteAuthMiddleware(req, res, next) {
176
+ if (isPublic(req.path)) return next();
177
+
178
+ var header = req.headers.authorization;
179
+ if (!header || header.indexOf('Bearer ') !== 0) {
180
+ return res.status(401).json({ error: 'No token provided' });
171
181
  }
182
+ var token = header.slice('Bearer '.length);
183
+ var method = req.method;
184
+ var path = (req.baseUrl || '') + req.path;
185
+ var key = token + '\n' + method + ' ' + path;
172
186
 
173
- next();
187
+ if (cacheSeconds > 0) {
188
+ var hit = cache.get(key);
189
+ if (hit && hit.expires > Date.now()) return apply(hit.outcome, req, res, next);
190
+ }
191
+
192
+ var inFlight = pending.get(key);
193
+ if (!inFlight) {
194
+ inFlight = ask(header, method, path);
195
+ pending.set(key, inFlight);
196
+ inFlight.then(function (outcome) {
197
+ pending.delete(key);
198
+ // NOT CACHED WHEN THE TOKEN SLID. The caller is being told to switch
199
+ // to a replacement, and caching the old one would answer its next few
200
+ // requests from memory — without the header telling it to switch. A
201
+ // client that missed the first one would never be told again, and
202
+ // would be cut off when the window closed.
203
+ //
204
+ // Only a real answer is cached: an outage or a bad token is asked
205
+ // again next time.
206
+ var answer = outcome.kind === 'ok' || outcome.kind === 'refused';
207
+ if (cacheSeconds > 0 && answer && !outcome.newToken) {
208
+ cache.set(key, { outcome: outcome, expires: Date.now() + cacheSeconds * 1000 });
209
+ }
210
+ });
211
+ }
212
+ apply(await inFlight, req, res, next);
174
213
  };
175
214
  }
176
215
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xeplr/base-apis",
3
- "version": "2.0.2",
3
+ "version": "2.0.4",
4
4
  "description": "Base API bootstrapper — Express and plain HTTP server factories with middleware injection",
5
5
  "main": "index.js",
6
6
  "bin": {