@technomoron/api-server-base 2.0.0-beta.17 → 2.0.0-beta.18
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.txt +48 -35
- package/dist/cjs/api-server-base.cjs +144 -45
- package/dist/cjs/api-server-base.d.ts +26 -2
- package/dist/cjs/auth-api/auth-module.js +55 -21
- package/dist/cjs/auth-api/mem-auth-store.js +2 -1
- package/dist/cjs/auth-api/sql-auth-store.js +2 -1
- package/dist/cjs/oauth/memory.js +2 -1
- package/dist/cjs/oauth/sequelize.js +2 -1
- package/dist/cjs/passkey/service.js +1 -1
- package/dist/cjs/passkey/types.d.ts +5 -0
- package/dist/esm/api-server-base.d.ts +26 -2
- package/dist/esm/api-server-base.js +144 -45
- package/dist/esm/auth-api/auth-module.js +55 -21
- package/dist/esm/auth-api/mem-auth-store.js +2 -1
- package/dist/esm/auth-api/sql-auth-store.js +2 -1
- package/dist/esm/oauth/memory.js +2 -1
- package/dist/esm/oauth/sequelize.js +2 -1
- package/dist/esm/passkey/service.js +1 -1
- package/dist/esm/passkey/types.d.ts +5 -0
- package/docs/swagger/openapi.json +11 -145
- package/package.json +12 -12
package/README.txt
CHANGED
|
@@ -25,13 +25,13 @@ pnpm add @technomoron/api-server-base
|
|
|
25
25
|
|
|
26
26
|
All runtime dependencies and `@types/*` packages are bundled with the distribution. The library exports ES modules by default. Consumers that rely on CommonJS can import via the require entry exposed in package.json.
|
|
27
27
|
|
|
28
|
-
Quick Start
|
|
29
|
-
-----------
|
|
30
|
-
import { ApiServer, ApiModule, ApiError,
|
|
28
|
+
Quick Start
|
|
29
|
+
-----------
|
|
30
|
+
import { ApiServer, ApiModule, ApiError, BaseAuthAdapter } from '@technomoron/api-server-base';
|
|
31
31
|
|
|
32
32
|
type DemoUser = { id: string; email: string; password: string };
|
|
33
33
|
|
|
34
|
-
class DemoStorage extends
|
|
34
|
+
class DemoStorage extends BaseAuthAdapter<DemoUser, Omit<DemoUser, 'password'>> {
|
|
35
35
|
private readonly users = new Map<string, DemoUser>([
|
|
36
36
|
['1', { id: '1', email: 'demo@example.com', password: 'secret' }]
|
|
37
37
|
]);
|
|
@@ -79,14 +79,15 @@ class UserModule extends ApiModule<AppServer> {
|
|
|
79
79
|
|
|
80
80
|
const yourStorageAdapter = new DemoStorage();
|
|
81
81
|
|
|
82
|
-
const server = new AppServer({
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
})
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
82
|
+
const server = new AppServer({
|
|
83
|
+
apiPort: 3101,
|
|
84
|
+
apiHost: '127.0.0.1',
|
|
85
|
+
accessSecret: 'replace-me'
|
|
86
|
+
})
|
|
87
|
+
.authStorage(yourStorageAdapter)
|
|
88
|
+
.api(new UserModule())
|
|
89
|
+
.finalize()
|
|
90
|
+
.start();
|
|
90
91
|
|
|
91
92
|
Need a dedicated auth module as well? Chain `.authModule(...)` in the same spot.
|
|
92
93
|
|
|
@@ -105,7 +106,11 @@ uploadMax (number, default 30 * 1024 * 1024) Maximum upload size in bytes.
|
|
|
105
106
|
staticDirs (record, default empty object) Map of mount path => disk path for serving static files as-is (ex: { '/assets': './public' }).
|
|
106
107
|
accessSecret (string, default empty string) Required for JWT signing and verification.
|
|
107
108
|
refreshSecret (string, default empty string) Used for refresh tokens if you implement them.
|
|
108
|
-
cookieDomain (string, default '
|
|
109
|
+
cookieDomain (string, default '') Domain applied to auth cookies.
|
|
110
|
+
cookiePath (string, default '/') Path applied to auth cookies.
|
|
111
|
+
cookieSameSite ('lax' | 'strict' | 'none', default 'lax') SameSite attribute applied to auth cookies.
|
|
112
|
+
cookieSecure (boolean | 'auto', default 'auto') Secure attribute applied to auth cookies; 'auto' enables Secure only when the request is HTTPS (or forwarded as HTTPS).
|
|
113
|
+
cookieHttpOnly (boolean, default true) HttpOnly attribute applied to auth cookies.
|
|
109
114
|
accessCookie (string, default 'dat') Access token cookie name.
|
|
110
115
|
refreshCookie (string, default 'drt') Refresh token cookie name.
|
|
111
116
|
accessExpiry (number, default 60 * 15) Access token lifetime in seconds.
|
|
@@ -120,14 +125,14 @@ refreshMaybe (boolean, default false) When true, `auth: maybe` routes will try t
|
|
|
120
125
|
|
|
121
126
|
Tip: If you add new configuration fields in downstream projects, extend ApiServerConf and update fillConfig so defaults stay aligned.
|
|
122
127
|
|
|
123
|
-
Request Lifecycle
|
|
124
|
-
-----------------
|
|
125
|
-
1. Express middlewares (express.json, cookie-parser, optional multer) run before your handler.
|
|
126
|
-
2. ApiServer wraps the route inside handle_request,
|
|
127
|
-
3. authenticate enforces the ApiRoute auth type: `none`, `maybe`, `yes`, `strict`, or `apikey`. Bearer JWTs and the access cookie (`accessCookie`, default `dat`) are accepted for `yes`/`strict`, while API key tokens prefixed with `apikey-` always delegate to `getApiKey`. When `refreshSecret` is configured and your storage supports refresh lookups (`getToken({ refreshToken })` + `updateToken(...)`), `yes`/`strict` routes will automatically mint a new access token when it is missing or expired (and also recover from "Authorization token is no longer valid" by refreshing). `maybe` routes only do the same when `refreshMaybe: true`. The optional `strict` type (or server-wide `validateTokens` flag) requires the signed JWT to exist in storage; when it does, the persisted row is attached to `apiReq.authToken`. The dedicated `apikey` type simply means “an API key is required”; otherwise API keys are still accepted by `yes`/`strict` routes alongside JWTs, and `apiReq.apiKey` is populated when present.
|
|
128
|
-
4. authorize runs with the requested auth class (any or admin in the base implementation). Override to connect to your role system.
|
|
129
|
-
5. The handler executes and returns its tuple. Responses are normalized to { code, message, data } JSON.
|
|
130
|
-
6. Errors bubble into the wrapper. ApiError instances respect the provided status codes; other exceptions result in a 500 with text derived from guessExceptionText.
|
|
128
|
+
Request Lifecycle
|
|
129
|
+
-----------------
|
|
130
|
+
1. Express middlewares (express.json, cookie-parser, optional multer) run before your handler.
|
|
131
|
+
2. ApiServer wraps the route inside handle_request, creating an ApiRequest and logging when debug is enabled.
|
|
132
|
+
3. authenticate enforces the ApiRoute auth type: `none`, `maybe`, `yes`, `strict`, or `apikey`. Bearer JWTs and the access cookie (`accessCookie`, default `dat`) are accepted for `yes`/`strict`, while API key tokens prefixed with `apikey-` always delegate to `getApiKey`. When `refreshSecret` is configured and your storage supports refresh lookups (`getToken({ refreshToken })` + `updateToken(...)`), `yes`/`strict` routes will automatically mint a new access token when it is missing or expired (and also recover from "Authorization token is no longer valid" by refreshing). `maybe` routes only do the same when `refreshMaybe: true`. The optional `strict` type (or server-wide `validateTokens` flag) requires the signed JWT to exist in storage; when it does, the persisted row is attached to `apiReq.authToken`. The dedicated `apikey` type simply means “an API key is required”; otherwise API keys are still accepted by `yes`/`strict` routes alongside JWTs, and `apiReq.apiKey` is populated when present.
|
|
133
|
+
4. authorize runs with the requested auth class (any or admin in the base implementation). Override to connect to your role system.
|
|
134
|
+
5. The handler executes and returns its tuple. Responses are normalized to { code, message, data } JSON.
|
|
135
|
+
6. Errors bubble into the wrapper. ApiError instances respect the provided status codes; other exceptions result in a 500 with text derived from guessExceptionText.
|
|
131
136
|
|
|
132
137
|
Client IP Helpers
|
|
133
138
|
-----------------
|
|
@@ -135,16 +140,20 @@ Call `apiReq.getClientInfo()` when you need the entire client fingerprint captur
|
|
|
135
140
|
|
|
136
141
|
Call `apiReq.getClientIp()` to obtain the most likely client address, skipping loopback entries collected from proxy headers. Use `apiReq.getClientIpChain()` when you need the de-duplicated sequence gathered from the standard Forwarded/X-Forwarded-For/X-Real-IP headers as well as Express' `req.ip`/`req.ips` and the underlying socket. Both helpers reuse the cached payload returned by `apiReq.getClientInfo()`.
|
|
137
142
|
|
|
138
|
-
Extending the Base Classes
|
|
139
|
-
--------------------------
|
|
140
|
-
Implement the AuthStorage contract (getUser, verifyPassword, storeToken, updateToken, etc.) to integrate with your persistence layer, then supply it via authStorage().
|
|
141
|
-
Use your storage adapter's filterUser helper to trim sensitive data before returning responses.
|
|
142
|
-
Provide your own authorize method to enforce role based access control using the ApiAuthClass enum.
|
|
143
|
-
Create feature modules by extending ApiModule. Use the optional checkConfig hook to validate prerequisites before routes mount.
|
|
143
|
+
Extending the Base Classes
|
|
144
|
+
--------------------------
|
|
145
|
+
Implement the AuthStorage contract (getUser, verifyPassword, storeToken, updateToken, etc.) to integrate with your persistence layer, then supply it via authStorage().
|
|
146
|
+
Use your storage adapter's filterUser helper to trim sensitive data before returning responses.
|
|
147
|
+
Provide your own authorize method to enforce role based access control using the ApiAuthClass enum.
|
|
148
|
+
Create feature modules by extending ApiModule. Use the optional checkConfig hook to validate prerequisites before routes mount.
|
|
149
|
+
|
|
150
|
+
OAuth Client Secrets
|
|
151
|
+
--------------------
|
|
152
|
+
If your OAuth client records use a client secret, make `getClient(clientId)` return a client with a truthy `clientSecret` (do not return the stored hash/secret itself) and implement `verifyClientSecret(client, clientSecret)` on your storage adapter. If `clientSecret` is truthy but `verifyClientSecret` is not overridden, the `/auth/v1/oauth2/token` endpoint returns 501.
|
|
144
153
|
|
|
145
|
-
Sequelize Table Prefixes
|
|
146
|
-
------------------------
|
|
147
|
-
Sequelize-backed stores accept `tablePrefix` to prepend to the built-in table names (`users`, `jwttokens`, `passkey_credentials`, `passkey_challenges`, `oauth_clients`, `oauth_codes`).
|
|
154
|
+
Sequelize Table Prefixes
|
|
155
|
+
------------------------
|
|
156
|
+
Sequelize-backed stores accept `tablePrefix` to prepend to the built-in table names (`users`, `jwttokens`, `passkey_credentials`, `passkey_challenges`, `oauth_clients`, `oauth_codes`).
|
|
148
157
|
|
|
149
158
|
SqlAuthStore supports both a global prefix (`tablePrefix`) and per-module overrides (`tablePrefixes.user|token|passkey|oauth`). When present, `tokenStoreOptions.tablePrefix` and `oauthStoreOptions.tablePrefix` take precedence.
|
|
150
159
|
|
|
@@ -158,16 +167,16 @@ Example:
|
|
|
158
167
|
|
|
159
168
|
If you need a different base name (for example `myapp_tokens` instead of `myapp_jwttokens`), pass a custom model or model factory to the store and set the `tableName` yourself.
|
|
160
169
|
|
|
161
|
-
Custom Express Endpoints
|
|
162
|
-
------------------------
|
|
163
|
-
ApiModule routes run inside the tuple wrapper (always responding with a standardized JSON envelope). For endpoints that need raw Express control (streaming, webhooks, tus uploads, etc.), mount your own handlers directly.
|
|
170
|
+
Custom Express Endpoints
|
|
171
|
+
------------------------
|
|
172
|
+
ApiModule routes run inside the tuple wrapper (always responding with a standardized JSON envelope). For endpoints that need raw Express control (streaming, webhooks, tus uploads, etc.), mount your own handlers directly.
|
|
164
173
|
|
|
165
174
|
- `server.useExpress(...)` mounts middleware/routes and keeps the built-in `/api` 404 handler ordered last, so mounts under `apiBasePath` are not intercepted.
|
|
166
175
|
- Protect endpoints by inserting `server.expressAuth({ type, req })` as middleware. It authenticates using the same JWT/cookie/API-key logic as ApiModule routes and then runs `authorize`.
|
|
167
176
|
- On success, `expressAuth` attaches the computed ApiRequest to both `req.apiReq` and `res.locals.apiReq`.
|
|
168
177
|
- If you want the same JSON error envelope for custom endpoints, mount `server.expressErrorHandler()` after your custom routes.
|
|
169
178
|
|
|
170
|
-
Example:
|
|
179
|
+
Example:
|
|
171
180
|
|
|
172
181
|
server
|
|
173
182
|
.useExpress(
|
|
@@ -178,7 +187,11 @@ Example:
|
|
|
178
187
|
res.status(200).json({ uid: apiReq.tokenData?.uid ?? null });
|
|
179
188
|
}
|
|
180
189
|
)
|
|
181
|
-
|
|
190
|
+
.useExpress(server.expressErrorHandler());
|
|
191
|
+
|
|
192
|
+
Finalize And Start
|
|
193
|
+
------------------
|
|
194
|
+
Call `server.finalize()` after you have mounted all ApiModules and custom Express endpoints. After finalize (or after `start()`), calling `api()` / `useExpress()` will throw.
|
|
182
195
|
|
|
183
196
|
|
|
184
197
|
Tooling and Scripts
|
|
@@ -351,7 +351,11 @@ function fillConfig(config) {
|
|
|
351
351
|
swaggerPath: config.swaggerPath ?? '',
|
|
352
352
|
accessSecret: config.accessSecret ?? '',
|
|
353
353
|
refreshSecret: config.refreshSecret ?? '',
|
|
354
|
-
cookieDomain: config.cookieDomain ?? '
|
|
354
|
+
cookieDomain: config.cookieDomain ?? '',
|
|
355
|
+
cookiePath: config.cookiePath ?? '/',
|
|
356
|
+
cookieSameSite: config.cookieSameSite ?? 'lax',
|
|
357
|
+
cookieSecure: config.cookieSecure ?? 'auto',
|
|
358
|
+
cookieHttpOnly: config.cookieHttpOnly ?? true,
|
|
355
359
|
accessCookie: config.accessCookie ?? 'dat',
|
|
356
360
|
refreshCookie: config.refreshCookie ?? 'drt',
|
|
357
361
|
accessExpiry: config.accessExpiry ?? 60 * 15,
|
|
@@ -369,8 +373,19 @@ function fillConfig(config) {
|
|
|
369
373
|
};
|
|
370
374
|
}
|
|
371
375
|
class ApiServer {
|
|
376
|
+
/**
|
|
377
|
+
* @deprecated ApiServer does not track a global "current request". This value is always null.
|
|
378
|
+
* Use the per-request ApiRequest passed to handlers, or `req.apiReq` / `res.locals.apiReq`
|
|
379
|
+
* when mounting raw Express endpoints.
|
|
380
|
+
*/
|
|
381
|
+
get currReq() {
|
|
382
|
+
return null;
|
|
383
|
+
}
|
|
384
|
+
set currReq(_value) {
|
|
385
|
+
void _value;
|
|
386
|
+
}
|
|
372
387
|
constructor(config = {}) {
|
|
373
|
-
this.
|
|
388
|
+
this.finalized = false;
|
|
374
389
|
this.serverAuthAdapter = null;
|
|
375
390
|
this.apiNotFoundHandler = null;
|
|
376
391
|
this.tokenStoreAdapter = null;
|
|
@@ -395,17 +410,67 @@ class ApiServer {
|
|
|
395
410
|
this.storageAdapter = this.getServerAuthAdapter();
|
|
396
411
|
}
|
|
397
412
|
this.app = (0, express_1.default)();
|
|
413
|
+
// Mount API modules and any custom endpoints under `apiBasePath` on this router so we can keep
|
|
414
|
+
// the API 404 handler ordered last without relying on Express internals.
|
|
415
|
+
this.apiRouter = express_1.default.Router();
|
|
398
416
|
if (config.uploadPath) {
|
|
399
|
-
const upload = (0, multer_1.default)({ dest: config.uploadPath });
|
|
417
|
+
const upload = (0, multer_1.default)({ dest: config.uploadPath, limits: { fileSize: this.config.uploadMax } });
|
|
400
418
|
this.app.use(upload.any());
|
|
419
|
+
// Multer errors happen before ApiModule route wrappers; format the common "too large" failure.
|
|
420
|
+
this.app.use((err, _req, res, next) => {
|
|
421
|
+
const code = err && typeof err === 'object' ? err.code : undefined;
|
|
422
|
+
if (code === 'LIMIT_FILE_SIZE') {
|
|
423
|
+
res.status(413).json({
|
|
424
|
+
success: false,
|
|
425
|
+
code: 413,
|
|
426
|
+
message: `Upload exceeds maximum size of ${this.config.uploadMax} bytes`,
|
|
427
|
+
data: null,
|
|
428
|
+
errors: {}
|
|
429
|
+
});
|
|
430
|
+
return;
|
|
431
|
+
}
|
|
432
|
+
next(err);
|
|
433
|
+
});
|
|
401
434
|
}
|
|
402
435
|
this.middlewares();
|
|
403
436
|
this.installStaticDirs();
|
|
404
437
|
this.installPingHandler();
|
|
405
438
|
this.installSwaggerHandler();
|
|
439
|
+
this.app.use(this.apiBasePath, this.apiRouter);
|
|
406
440
|
// addSwaggerUi(this.app);
|
|
407
441
|
this.installApiNotFoundHandler();
|
|
408
442
|
}
|
|
443
|
+
assertNotFinalized(action) {
|
|
444
|
+
if (this.finalized) {
|
|
445
|
+
throw new Error(`Cannot call ApiServer.${action}() after finalize()/start()`);
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
toApiRouterPath(candidate) {
|
|
449
|
+
if (typeof candidate !== 'string') {
|
|
450
|
+
return null;
|
|
451
|
+
}
|
|
452
|
+
const trimmed = candidate.trim();
|
|
453
|
+
if (!trimmed) {
|
|
454
|
+
return null;
|
|
455
|
+
}
|
|
456
|
+
const normalized = trimmed.startsWith('/') ? trimmed : `/${trimmed}`;
|
|
457
|
+
const base = this.apiBasePath;
|
|
458
|
+
if (base === '/') {
|
|
459
|
+
return normalized;
|
|
460
|
+
}
|
|
461
|
+
if (normalized === base) {
|
|
462
|
+
return '/';
|
|
463
|
+
}
|
|
464
|
+
if (normalized.startsWith(`${base}/`)) {
|
|
465
|
+
return normalized.slice(base.length) || '/';
|
|
466
|
+
}
|
|
467
|
+
return null;
|
|
468
|
+
}
|
|
469
|
+
finalize() {
|
|
470
|
+
this.installApiNotFoundHandler();
|
|
471
|
+
this.finalized = true;
|
|
472
|
+
return this;
|
|
473
|
+
}
|
|
409
474
|
authStorage(storage) {
|
|
410
475
|
this.storageAdapter = storage;
|
|
411
476
|
return this;
|
|
@@ -631,7 +696,7 @@ class ApiServer {
|
|
|
631
696
|
}
|
|
632
697
|
return false;
|
|
633
698
|
}
|
|
634
|
-
guessExceptionText(error, defMsg = '
|
|
699
|
+
guessExceptionText(error, defMsg = 'Unknown Error') {
|
|
635
700
|
return guess_exception_text(error, defMsg);
|
|
636
701
|
}
|
|
637
702
|
async authorize(apiReq, requiredClass) {
|
|
@@ -657,6 +722,26 @@ class ApiServer {
|
|
|
657
722
|
credentials: true
|
|
658
723
|
};
|
|
659
724
|
this.app.use((0, cors_1.default)(corsOptions));
|
|
725
|
+
// Provide a consistent and non-500 response when requests are blocked by the CORS allowlist.
|
|
726
|
+
this.app.use((err, req, res, next) => {
|
|
727
|
+
const message = err instanceof Error ? err.message : '';
|
|
728
|
+
if (message.includes('Not allowed by CORS')) {
|
|
729
|
+
const isApiRequest = Boolean(req.originalUrl?.startsWith(this.apiBasePath));
|
|
730
|
+
if (isApiRequest) {
|
|
731
|
+
res.status(403).json({
|
|
732
|
+
success: false,
|
|
733
|
+
code: 403,
|
|
734
|
+
message: 'Origin not allowed by CORS',
|
|
735
|
+
data: null,
|
|
736
|
+
errors: {}
|
|
737
|
+
});
|
|
738
|
+
return;
|
|
739
|
+
}
|
|
740
|
+
res.status(403).send('Origin not allowed by CORS');
|
|
741
|
+
return;
|
|
742
|
+
}
|
|
743
|
+
next(err);
|
|
744
|
+
});
|
|
660
745
|
}
|
|
661
746
|
installStaticDirs() {
|
|
662
747
|
const staticDirs = this.config.staticDirs;
|
|
@@ -770,28 +855,16 @@ class ApiServer {
|
|
|
770
855
|
};
|
|
771
856
|
this.app.use(this.apiBasePath, this.apiNotFoundHandler);
|
|
772
857
|
}
|
|
773
|
-
ensureApiNotFoundOrdering() {
|
|
774
|
-
this.installApiNotFoundHandler();
|
|
775
|
-
if (!this.apiNotFoundHandler) {
|
|
776
|
-
return;
|
|
777
|
-
}
|
|
778
|
-
const stack = this.app._router?.stack;
|
|
779
|
-
if (!stack) {
|
|
780
|
-
return;
|
|
781
|
-
}
|
|
782
|
-
const index = stack.findIndex((layer) => layer.handle === this.apiNotFoundHandler);
|
|
783
|
-
if (index === -1 || index === stack.length - 1) {
|
|
784
|
-
return;
|
|
785
|
-
}
|
|
786
|
-
const [layer] = stack.splice(index, 1);
|
|
787
|
-
stack.push(layer);
|
|
788
|
-
}
|
|
789
858
|
describeMissingEndpoint(req) {
|
|
790
859
|
const method = typeof req.method === 'string' ? req.method.toUpperCase() : 'GET';
|
|
791
860
|
const target = req.originalUrl || req.url || this.apiBasePath;
|
|
792
861
|
return `No such endpoint: ${method} ${target}`;
|
|
793
862
|
}
|
|
794
863
|
start() {
|
|
864
|
+
if (!this.finalized) {
|
|
865
|
+
console.warn('[api-server-base] ApiServer.start() called without finalize(); auto-finalizing. Call server.finalize() before start() to silence this warning.');
|
|
866
|
+
this.finalize();
|
|
867
|
+
}
|
|
795
868
|
this.app
|
|
796
869
|
.listen({
|
|
797
870
|
port: this.config.apiPort,
|
|
@@ -801,19 +874,22 @@ class ApiServer {
|
|
|
801
874
|
console.log(`Server is running on http://${this.config.apiHost}:${this.config.apiPort}`);
|
|
802
875
|
})
|
|
803
876
|
.on('error', (error) => {
|
|
877
|
+
let message;
|
|
804
878
|
if (error.code === 'EADDRINUSE') {
|
|
805
|
-
|
|
879
|
+
message = `Port ${this.config.apiPort} is already in use.`;
|
|
806
880
|
}
|
|
807
881
|
else if (error.code === 'EACCES') {
|
|
808
|
-
|
|
882
|
+
message = `Insufficient permissions to bind to port ${this.config.apiPort}. Try using a port number >= 1024 or running with elevated privileges.`;
|
|
809
883
|
}
|
|
810
884
|
else if (error.code === 'EADDRNOTAVAIL') {
|
|
811
|
-
|
|
885
|
+
message = `Address ${this.config.apiHost} is not available on this machine.`;
|
|
812
886
|
}
|
|
813
887
|
else {
|
|
814
|
-
|
|
888
|
+
message = `Failed to start server: ${error.message}`;
|
|
815
889
|
}
|
|
816
|
-
|
|
890
|
+
const err = new Error(message);
|
|
891
|
+
err.cause = error;
|
|
892
|
+
throw err;
|
|
817
893
|
});
|
|
818
894
|
return this;
|
|
819
895
|
}
|
|
@@ -835,23 +911,33 @@ class ApiServer {
|
|
|
835
911
|
const forwarded = apiReq.req.headers['x-forwarded-proto'];
|
|
836
912
|
const referer = apiReq.req.headers['origin'] ?? apiReq.req.headers['referer'];
|
|
837
913
|
const origin = typeof referer === 'string' ? referer : '';
|
|
838
|
-
const
|
|
914
|
+
const forwardedProto = (typeof forwarded === 'string' ? forwarded : Array.isArray(forwarded) ? (forwarded[0] ?? '') : '')
|
|
915
|
+
.split(',')[0]
|
|
916
|
+
.trim()
|
|
917
|
+
.toLowerCase();
|
|
918
|
+
const isHttps = forwardedProto === 'https' || apiReq.req.protocol === 'https';
|
|
839
919
|
const isLocalhost = origin.includes('localhost');
|
|
920
|
+
const secure = conf.cookieSecure === true ? true : conf.cookieSecure === false ? false : /* auto */ Boolean(isHttps);
|
|
921
|
+
let sameSite = conf.cookieSameSite ?? 'lax';
|
|
922
|
+
if (sameSite !== 'lax' && sameSite !== 'strict' && sameSite !== 'none') {
|
|
923
|
+
sameSite = 'lax';
|
|
924
|
+
}
|
|
925
|
+
let resolvedSecure = secure;
|
|
926
|
+
if (sameSite === 'none' && resolvedSecure !== true) {
|
|
927
|
+
// Modern browsers reject SameSite=None cookies unless Secure is set.
|
|
928
|
+
resolvedSecure = true;
|
|
929
|
+
}
|
|
840
930
|
const options = {
|
|
841
|
-
httpOnly: true,
|
|
842
|
-
secure:
|
|
843
|
-
sameSite
|
|
931
|
+
httpOnly: conf.cookieHttpOnly ?? true,
|
|
932
|
+
secure: resolvedSecure,
|
|
933
|
+
sameSite,
|
|
844
934
|
domain: conf.cookieDomain || undefined,
|
|
845
|
-
path: '/',
|
|
935
|
+
path: conf.cookiePath || '/',
|
|
846
936
|
maxAge: undefined
|
|
847
937
|
};
|
|
848
|
-
if (conf.devMode) {
|
|
849
|
-
|
|
850
|
-
options.
|
|
851
|
-
options.sameSite = 'lax';
|
|
852
|
-
if (isLocalhost) {
|
|
853
|
-
options.domain = undefined;
|
|
854
|
-
}
|
|
938
|
+
if (conf.devMode && isLocalhost) {
|
|
939
|
+
// Domain cookies do not work on localhost; avoid breaking local development when cookieDomain is set.
|
|
940
|
+
options.domain = undefined;
|
|
855
941
|
}
|
|
856
942
|
return options;
|
|
857
943
|
}
|
|
@@ -999,7 +1085,7 @@ class ApiServer {
|
|
|
999
1085
|
}
|
|
1000
1086
|
}
|
|
1001
1087
|
if (!tokenData) {
|
|
1002
|
-
throw new ApiError({ code: 401, message: '
|
|
1088
|
+
throw new ApiError({ code: 401, message: 'Unauthorized Access - ' + error });
|
|
1003
1089
|
}
|
|
1004
1090
|
const effectiveUserId = this.extractTokenUserId(tokenData);
|
|
1005
1091
|
apiReq.realUid = this.resolveRealUserId(tokenData, effectiveUserId);
|
|
@@ -1051,6 +1137,11 @@ class ApiServer {
|
|
|
1051
1137
|
}
|
|
1052
1138
|
apiReq.token = secret;
|
|
1053
1139
|
apiReq.apiKey = key;
|
|
1140
|
+
// Treat API keys as authenticated identities, consistent with JWT-based flows.
|
|
1141
|
+
const resolvedUid = this.normalizeAuthIdentifier(key.uid);
|
|
1142
|
+
if (resolvedUid !== null) {
|
|
1143
|
+
apiReq.realUid = resolvedUid;
|
|
1144
|
+
}
|
|
1054
1145
|
return {
|
|
1055
1146
|
uid: key.uid,
|
|
1056
1147
|
domain: '',
|
|
@@ -1110,13 +1201,19 @@ class ApiServer {
|
|
|
1110
1201
|
return rawReal;
|
|
1111
1202
|
}
|
|
1112
1203
|
useExpress(pathOrHandler, ...handlers) {
|
|
1204
|
+
this.assertNotFinalized('useExpress');
|
|
1113
1205
|
if (typeof pathOrHandler === 'string') {
|
|
1114
|
-
this.
|
|
1206
|
+
const apiPath = this.toApiRouterPath(pathOrHandler);
|
|
1207
|
+
if (apiPath) {
|
|
1208
|
+
this.apiRouter.use(apiPath, ...handlers);
|
|
1209
|
+
}
|
|
1210
|
+
else {
|
|
1211
|
+
this.app.use(pathOrHandler, ...handlers);
|
|
1212
|
+
}
|
|
1115
1213
|
}
|
|
1116
1214
|
else {
|
|
1117
1215
|
this.app.use(pathOrHandler, ...handlers);
|
|
1118
1216
|
}
|
|
1119
|
-
this.ensureApiNotFoundOrdering();
|
|
1120
1217
|
return this;
|
|
1121
1218
|
}
|
|
1122
1219
|
createApiRequest(req, res) {
|
|
@@ -1150,7 +1247,6 @@ class ApiServer {
|
|
|
1150
1247
|
const apiReq = this.createApiRequest(req, res);
|
|
1151
1248
|
req.apiReq = apiReq;
|
|
1152
1249
|
res.locals.apiReq = apiReq;
|
|
1153
|
-
this.currReq = apiReq;
|
|
1154
1250
|
try {
|
|
1155
1251
|
if (this.config.hydrateGetBody) {
|
|
1156
1252
|
hydrateGetBody(req);
|
|
@@ -1203,7 +1299,6 @@ class ApiServer {
|
|
|
1203
1299
|
return async (req, res, next) => {
|
|
1204
1300
|
void next;
|
|
1205
1301
|
const apiReq = this.createApiRequest(req, res);
|
|
1206
|
-
this.currReq = apiReq;
|
|
1207
1302
|
try {
|
|
1208
1303
|
if (this.config.hydrateGetBody) {
|
|
1209
1304
|
hydrateGetBody(apiReq.req);
|
|
@@ -1266,13 +1361,18 @@ class ApiServer {
|
|
|
1266
1361
|
};
|
|
1267
1362
|
}
|
|
1268
1363
|
api(module) {
|
|
1364
|
+
this.assertNotFinalized('api');
|
|
1269
1365
|
const router = express_1.default.Router();
|
|
1270
1366
|
module.server = this;
|
|
1271
1367
|
const moduleType = module.moduleType;
|
|
1272
1368
|
if (moduleType === 'auth') {
|
|
1273
1369
|
this.authModule(module);
|
|
1274
1370
|
}
|
|
1275
|
-
module.checkConfig();
|
|
1371
|
+
const configOk = module.checkConfig();
|
|
1372
|
+
if (configOk === false) {
|
|
1373
|
+
const name = module.constructor?.name ? String(module.constructor.name) : 'ApiModule';
|
|
1374
|
+
throw new Error(`${name}.checkConfig() returned false`);
|
|
1375
|
+
}
|
|
1276
1376
|
const base = this.apiBasePath;
|
|
1277
1377
|
const ns = module.namespace;
|
|
1278
1378
|
const mountPath = `${base}${ns}`;
|
|
@@ -1297,8 +1397,7 @@ class ApiServer {
|
|
|
1297
1397
|
console.log(`Adding ${mountPath}${r.path} (${r.method.toUpperCase()})`);
|
|
1298
1398
|
}
|
|
1299
1399
|
});
|
|
1300
|
-
this.
|
|
1301
|
-
this.ensureApiNotFoundOrdering();
|
|
1400
|
+
this.apiRouter.use(ns, router);
|
|
1302
1401
|
return this;
|
|
1303
1402
|
}
|
|
1304
1403
|
dumpRequest(apiReq) {
|
|
@@ -100,7 +100,21 @@ export interface ApiServerConf {
|
|
|
100
100
|
swaggerPath?: string;
|
|
101
101
|
accessSecret: string;
|
|
102
102
|
refreshSecret: string;
|
|
103
|
+
/** Cookie domain for auth cookies. Prefer leaving empty for localhost/development. */
|
|
103
104
|
cookieDomain: string;
|
|
105
|
+
/** Cookie path for auth cookies. */
|
|
106
|
+
cookiePath?: string;
|
|
107
|
+
/** Cookie SameSite attribute for auth cookies. */
|
|
108
|
+
cookieSameSite?: 'lax' | 'strict' | 'none';
|
|
109
|
+
/**
|
|
110
|
+
* Cookie Secure attribute for auth cookies.
|
|
111
|
+
* - true: always secure
|
|
112
|
+
* - false: never secure
|
|
113
|
+
* - 'auto': secure when request is HTTPS (or forwarded as HTTPS)
|
|
114
|
+
*/
|
|
115
|
+
cookieSecure?: boolean | 'auto';
|
|
116
|
+
/** Cookie HttpOnly attribute for auth cookies. */
|
|
117
|
+
cookieHttpOnly?: boolean;
|
|
104
118
|
accessCookie: string;
|
|
105
119
|
refreshCookie: string;
|
|
106
120
|
accessExpiry: number;
|
|
@@ -118,10 +132,11 @@ export interface ApiServerConf {
|
|
|
118
132
|
}
|
|
119
133
|
export declare class ApiServer {
|
|
120
134
|
app: Application;
|
|
121
|
-
currReq: ApiRequest | null;
|
|
122
135
|
readonly config: ApiServerConf;
|
|
123
136
|
readonly startedAt: number;
|
|
124
137
|
private readonly apiBasePath;
|
|
138
|
+
private readonly apiRouter;
|
|
139
|
+
private finalized;
|
|
125
140
|
private storageAdapter;
|
|
126
141
|
private moduleAdapter;
|
|
127
142
|
private serverAuthAdapter;
|
|
@@ -132,7 +147,17 @@ export declare class ApiServer {
|
|
|
132
147
|
private oauthStoreAdapter;
|
|
133
148
|
private canImpersonateAdapter;
|
|
134
149
|
private readonly jwtHelper;
|
|
150
|
+
/**
|
|
151
|
+
* @deprecated ApiServer does not track a global "current request". This value is always null.
|
|
152
|
+
* Use the per-request ApiRequest passed to handlers, or `req.apiReq` / `res.locals.apiReq`
|
|
153
|
+
* when mounting raw Express endpoints.
|
|
154
|
+
*/
|
|
155
|
+
get currReq(): ApiRequest | null;
|
|
156
|
+
set currReq(_value: ApiRequest | null);
|
|
135
157
|
constructor(config?: Partial<ApiServerConf>);
|
|
158
|
+
private assertNotFinalized;
|
|
159
|
+
private toApiRouterPath;
|
|
160
|
+
finalize(): this;
|
|
136
161
|
authStorage<UserRow, SafeUser>(storage: AuthAdapter<UserRow, SafeUser>): this;
|
|
137
162
|
/**
|
|
138
163
|
* @deprecated Use {@link ApiServer.authStorage} instead.
|
|
@@ -200,7 +225,6 @@ export declare class ApiServer {
|
|
|
200
225
|
private installSwaggerHandler;
|
|
201
226
|
private normalizeApiBasePath;
|
|
202
227
|
private installApiNotFoundHandler;
|
|
203
|
-
private ensureApiNotFoundOrdering;
|
|
204
228
|
private describeMissingEndpoint;
|
|
205
229
|
start(): this;
|
|
206
230
|
private verifyJWT;
|
|
@@ -240,23 +240,31 @@ class AuthModule extends module_js_1.BaseAuthModule {
|
|
|
240
240
|
const forwarded = apiReq.req.headers['x-forwarded-proto'];
|
|
241
241
|
const referer = apiReq.req.headers['origin'] ?? apiReq.req.headers['referer'];
|
|
242
242
|
const origin = typeof referer === 'string' ? referer : '';
|
|
243
|
-
const
|
|
243
|
+
const forwardedProto = (typeof forwarded === 'string' ? forwarded : Array.isArray(forwarded) ? (forwarded[0] ?? '') : '')
|
|
244
|
+
.split(',')[0]
|
|
245
|
+
.trim()
|
|
246
|
+
.toLowerCase();
|
|
247
|
+
const isHttps = forwardedProto === 'https' || apiReq.req.protocol === 'https';
|
|
244
248
|
const isLocalhost = origin.includes('localhost');
|
|
249
|
+
const secure = conf.cookieSecure === true ? true : conf.cookieSecure === false ? false : /* auto */ Boolean(isHttps);
|
|
250
|
+
let sameSite = conf.cookieSameSite ?? 'lax';
|
|
251
|
+
if (sameSite !== 'lax' && sameSite !== 'strict' && sameSite !== 'none') {
|
|
252
|
+
sameSite = 'lax';
|
|
253
|
+
}
|
|
254
|
+
let resolvedSecure = secure;
|
|
255
|
+
if (sameSite === 'none' && resolvedSecure !== true) {
|
|
256
|
+
resolvedSecure = true;
|
|
257
|
+
}
|
|
245
258
|
const options = {
|
|
246
|
-
httpOnly: true,
|
|
247
|
-
secure:
|
|
248
|
-
sameSite
|
|
259
|
+
httpOnly: conf.cookieHttpOnly ?? true,
|
|
260
|
+
secure: resolvedSecure,
|
|
261
|
+
sameSite,
|
|
249
262
|
domain: conf.cookieDomain || undefined,
|
|
250
|
-
path: '/',
|
|
263
|
+
path: conf.cookiePath || '/',
|
|
251
264
|
maxAge: undefined
|
|
252
265
|
};
|
|
253
|
-
if (conf.devMode) {
|
|
254
|
-
options.
|
|
255
|
-
options.httpOnly = false;
|
|
256
|
-
options.sameSite = 'lax';
|
|
257
|
-
if (isLocalhost) {
|
|
258
|
-
options.domain = undefined;
|
|
259
|
-
}
|
|
266
|
+
if (conf.devMode && isLocalhost) {
|
|
267
|
+
options.domain = undefined;
|
|
260
268
|
}
|
|
261
269
|
return options;
|
|
262
270
|
}
|
|
@@ -552,10 +560,39 @@ class AuthModule extends module_js_1.BaseAuthModule {
|
|
|
552
560
|
apiReq.req.cookies[conf.accessCookie].trim().length > 0);
|
|
553
561
|
const shouldRefresh = Boolean(body.refresh) || !hasAccessToken;
|
|
554
562
|
if (shouldRefresh) {
|
|
555
|
-
const
|
|
563
|
+
const updateToken = this.storage.updateToken;
|
|
564
|
+
if (typeof updateToken !== 'function' || !this.storageImplements('updateToken')) {
|
|
565
|
+
throw new api_server_base_js_1.ApiError({ code: 501, message: 'Token update storage is not configured' });
|
|
566
|
+
}
|
|
567
|
+
// Sign a new access token without embedding stored token secrets into the JWT payload.
|
|
568
|
+
const metadata = {
|
|
569
|
+
ruid: stored.ruid,
|
|
570
|
+
domain: stored.domain,
|
|
571
|
+
fingerprint: stored.fingerprint,
|
|
572
|
+
label: stored.label,
|
|
573
|
+
clientId: stored.clientId,
|
|
574
|
+
scope: stored.scope,
|
|
575
|
+
browser: stored.browser,
|
|
576
|
+
device: stored.device,
|
|
577
|
+
ip: stored.ip,
|
|
578
|
+
os: stored.os,
|
|
579
|
+
loginType: stored.loginType,
|
|
580
|
+
refreshTtlSeconds: this.normalizeRefreshTtlSeconds(stored.refreshTtlSeconds),
|
|
581
|
+
sessionCookie: stored.sessionCookie
|
|
582
|
+
};
|
|
583
|
+
const enrichedMetadata = this.enrichTokenMetadata(apiReq, metadata);
|
|
584
|
+
const access = this.server.jwtSign(this.buildTokenPayload(user, enrichedMetadata), conf.accessSecret, conf.accessExpiry);
|
|
556
585
|
if (!access.success || !access.token) {
|
|
557
586
|
throw new api_server_base_js_1.ApiError({ code: 500, message: access.error ?? 'Unable to sign access token' });
|
|
558
587
|
}
|
|
588
|
+
const updated = await updateToken.call(this.storage, {
|
|
589
|
+
refreshToken,
|
|
590
|
+
accessToken: access.token,
|
|
591
|
+
lastSeenAt: new Date()
|
|
592
|
+
});
|
|
593
|
+
if (!updated) {
|
|
594
|
+
throw new api_server_base_js_1.ApiError({ code: 500, message: 'Unable to persist refreshed access token' });
|
|
595
|
+
}
|
|
559
596
|
const cookiePrefs = this.mergeSessionPreferences({
|
|
560
597
|
sessionCookie: stored.sessionCookie,
|
|
561
598
|
refreshTtlSeconds: this.normalizeRefreshTtlSeconds(stored.refreshTtlSeconds)
|
|
@@ -1001,14 +1038,11 @@ class AuthModule extends module_js_1.BaseAuthModule {
|
|
|
1001
1038
|
if (!secretProvided) {
|
|
1002
1039
|
throw new api_server_base_js_1.ApiError({ code: 400, message: 'Client authentication is required' });
|
|
1003
1040
|
}
|
|
1004
|
-
|
|
1005
|
-
if (this.
|
|
1006
|
-
|
|
1007
|
-
valid = await verifySecret(client, clientSecret);
|
|
1008
|
-
}
|
|
1009
|
-
else {
|
|
1010
|
-
valid = client.clientSecret === clientSecret;
|
|
1041
|
+
const verifySecret = this.storage.verifyClientSecret;
|
|
1042
|
+
if (typeof verifySecret !== 'function' || !this.storageImplements('verifyClientSecret')) {
|
|
1043
|
+
throw new api_server_base_js_1.ApiError({ code: 501, message: 'OAuth client secret verification is not configured' });
|
|
1011
1044
|
}
|
|
1045
|
+
const valid = await verifySecret.call(this.storage, client, clientSecret);
|
|
1012
1046
|
if (!valid) {
|
|
1013
1047
|
throw new api_server_base_js_1.ApiError({ code: 401, message: 'Invalid client credentials' });
|
|
1014
1048
|
}
|
|
@@ -1156,7 +1190,7 @@ class AuthModule extends module_js_1.BaseAuthModule {
|
|
|
1156
1190
|
auth: { type: 'strict', req: 'any' }
|
|
1157
1191
|
}, {
|
|
1158
1192
|
method: 'delete',
|
|
1159
|
-
path: '/v1/passkeys/:credentialId
|
|
1193
|
+
path: '/v1/passkeys/:credentialId',
|
|
1160
1194
|
handler: (req) => this.deletePasskey(req),
|
|
1161
1195
|
auth: { type: 'strict', req: 'any' }
|
|
1162
1196
|
});
|
|
@@ -25,7 +25,8 @@ function normalizePasskeyConfig(config = {}) {
|
|
|
25
25
|
timeoutMs: typeof config.timeoutMs === 'number' && config.timeoutMs > 0
|
|
26
26
|
? config.timeoutMs
|
|
27
27
|
: DEFAULT_PASSKEY_CONFIG.timeoutMs,
|
|
28
|
-
userVerification: config.userVerification ?? DEFAULT_PASSKEY_CONFIG.userVerification
|
|
28
|
+
userVerification: config.userVerification ?? DEFAULT_PASSKEY_CONFIG.userVerification,
|
|
29
|
+
debug: Boolean(config.debug)
|
|
29
30
|
};
|
|
30
31
|
}
|
|
31
32
|
class MemAuthStore {
|