@technomoron/api-server-base 2.0.0-beta.1 → 2.0.0-beta.11
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 +25 -2
- package/dist/cjs/api-server-base.cjs +277 -41
- package/dist/cjs/api-server-base.d.ts +27 -7
- package/dist/cjs/auth-api/auth-module.d.ts +11 -2
- package/dist/cjs/auth-api/auth-module.js +215 -46
- package/dist/cjs/auth-api/compat-auth-storage.d.ts +7 -5
- package/dist/cjs/auth-api/compat-auth-storage.js +15 -3
- package/dist/cjs/auth-api/mem-auth-store.d.ts +5 -3
- package/dist/cjs/auth-api/mem-auth-store.js +7 -1
- package/dist/cjs/auth-api/sql-auth-store.d.ts +5 -3
- package/dist/cjs/auth-api/sql-auth-store.js +7 -1
- package/dist/cjs/auth-api/storage.d.ts +6 -4
- package/dist/cjs/auth-api/storage.js +15 -5
- package/dist/cjs/auth-api/types.d.ts +7 -2
- package/dist/cjs/index.cjs +4 -4
- package/dist/cjs/index.d.ts +4 -4
- package/dist/cjs/oauth/sequelize.js +1 -1
- package/dist/cjs/passkey/base.d.ts +1 -0
- package/dist/cjs/passkey/memory.d.ts +1 -0
- package/dist/cjs/passkey/memory.js +4 -0
- package/dist/cjs/passkey/sequelize.d.ts +1 -0
- package/dist/cjs/passkey/sequelize.js +11 -2
- package/dist/cjs/passkey/service.d.ts +5 -2
- package/dist/cjs/passkey/service.js +145 -10
- package/dist/cjs/passkey/types.d.ts +3 -0
- package/dist/cjs/user/base.js +2 -1
- package/dist/esm/api-server-base.d.ts +27 -7
- package/dist/esm/api-server-base.js +278 -42
- package/dist/esm/auth-api/auth-module.d.ts +11 -2
- package/dist/esm/auth-api/auth-module.js +216 -47
- package/dist/esm/auth-api/compat-auth-storage.d.ts +7 -5
- package/dist/esm/auth-api/compat-auth-storage.js +13 -1
- package/dist/esm/auth-api/mem-auth-store.d.ts +5 -3
- package/dist/esm/auth-api/mem-auth-store.js +8 -2
- package/dist/esm/auth-api/sql-auth-store.d.ts +5 -3
- package/dist/esm/auth-api/sql-auth-store.js +8 -2
- package/dist/esm/auth-api/storage.d.ts +6 -4
- package/dist/esm/auth-api/storage.js +13 -3
- package/dist/esm/auth-api/types.d.ts +7 -2
- package/dist/esm/index.d.ts +4 -4
- package/dist/esm/index.js +2 -2
- package/dist/esm/oauth/sequelize.js +1 -1
- package/dist/esm/passkey/base.d.ts +1 -0
- package/dist/esm/passkey/memory.d.ts +1 -0
- package/dist/esm/passkey/memory.js +4 -0
- package/dist/esm/passkey/sequelize.d.ts +1 -0
- package/dist/esm/passkey/sequelize.js +11 -2
- package/dist/esm/passkey/service.d.ts +5 -2
- package/dist/esm/passkey/service.js +113 -11
- package/dist/esm/passkey/types.d.ts +3 -0
- package/dist/esm/user/base.js +2 -1
- package/package.json +13 -11
package/README.txt
CHANGED
|
@@ -108,13 +108,14 @@ cookieDomain (string, default '.somewhere-over-the-rainbow.com') Domain applied
|
|
|
108
108
|
accessCookie (string, default 'dat') Access token cookie name.
|
|
109
109
|
refreshCookie (string, default 'drt') Refresh token cookie name.
|
|
110
110
|
accessExpiry (number, default 60 * 15) Access token lifetime in seconds.
|
|
111
|
-
refreshExpiry (number, default 30 * 24 * 60 * 60
|
|
111
|
+
refreshExpiry (number, default 30 * 24 * 60 * 60) Refresh token lifetime in seconds.
|
|
112
112
|
sessionRefreshExpiry (number, default 24 * 60 * 60) Session token lifetime in seconds when clients opt out of "remember me" cookies.
|
|
113
113
|
authApi (boolean, default false) Toggle you can use when mounting auth routes.
|
|
114
114
|
devMode (boolean, default false) Custom hook for development only features.
|
|
115
115
|
debug (boolean, default false) When true the server logs inbound requests via dumpRequest.
|
|
116
116
|
hydrateGetBody (boolean, default true) Copy query parameters into `req.body` for GET requests; set false if you prefer untouched bodies.
|
|
117
117
|
validateTokens (boolean, default false) When true, every JWT-authenticated request must match a stored token row (access token + user id) before reaching your handler. API keys remain stateless either way.
|
|
118
|
+
refreshMaybe (boolean, default false) When true, `auth: maybe` routes will try to refresh a missing/expired access token using the refresh cookie; if refresh fails, the request stays anonymous.
|
|
118
119
|
|
|
119
120
|
Tip: If you add new configuration fields in downstream projects, extend ApiServerConf and update fillConfig so defaults stay aligned.
|
|
120
121
|
|
|
@@ -122,7 +123,7 @@ Request Lifecycle
|
|
|
122
123
|
-----------------
|
|
123
124
|
1. Express middlewares (express.json, cookie-parser, optional multer) run before your handler.
|
|
124
125
|
2. ApiServer wraps the route inside handle_request, setting currReq and logging when debug is enabled.
|
|
125
|
-
3. authenticate enforces the ApiRoute auth type: `none`, `maybe`, `yes`, `strict`, or `apikey`. Bearer JWTs and the `dat`
|
|
126
|
+
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.
|
|
126
127
|
4. authorize runs with the requested auth class (any or admin in the base implementation). Override to connect to your role system.
|
|
127
128
|
5. The handler executes and returns its tuple. Responses are normalized to { code, message, data } JSON.
|
|
128
129
|
6. Errors bubble into the wrapper. ApiError instances respect the provided status codes; other exceptions result in a 500 with text derived from guessExceptionText.
|
|
@@ -140,6 +141,28 @@ Use your storage adapter's filterUser helper to trim sensitive data before retur
|
|
|
140
141
|
Provide your own authorize method to enforce role based access control using the ApiAuthClass enum.
|
|
141
142
|
Create feature modules by extending ApiModule. Use the optional checkConfig hook to validate prerequisites before routes mount.
|
|
142
143
|
|
|
144
|
+
Custom Express Endpoints
|
|
145
|
+
------------------------
|
|
146
|
+
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.
|
|
147
|
+
|
|
148
|
+
- `server.useExpress(...)` mounts middleware/routes and keeps the built-in `/api` 404 handler ordered last, so mounts under `apiBasePath` are not intercepted.
|
|
149
|
+
- 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`.
|
|
150
|
+
- On success, `expressAuth` attaches the computed ApiRequest to both `req.apiReq` and `res.locals.apiReq`.
|
|
151
|
+
- If you want the same JSON error envelope for custom endpoints, mount `server.expressErrorHandler()` after your custom routes.
|
|
152
|
+
|
|
153
|
+
Example:
|
|
154
|
+
|
|
155
|
+
server
|
|
156
|
+
.useExpress(
|
|
157
|
+
'/api/custom/optional',
|
|
158
|
+
server.expressAuth({ type: 'maybe', req: 'any' }),
|
|
159
|
+
(req, res) => {
|
|
160
|
+
const apiReq = (req as any).apiReq;
|
|
161
|
+
res.status(200).json({ uid: apiReq.tokenData?.uid ?? null });
|
|
162
|
+
}
|
|
163
|
+
)
|
|
164
|
+
.useExpress(server.expressErrorHandler());
|
|
165
|
+
|
|
143
166
|
|
|
144
167
|
Tooling and Scripts
|
|
145
168
|
-------------------
|
|
@@ -352,6 +352,7 @@ function fillConfig(config) {
|
|
|
352
352
|
devMode: config.devMode ?? false,
|
|
353
353
|
hydrateGetBody: config.hydrateGetBody ?? true,
|
|
354
354
|
validateTokens: config.validateTokens ?? false,
|
|
355
|
+
refreshMaybe: config.refreshMaybe ?? false,
|
|
355
356
|
apiVersion: config.apiVersion ?? '',
|
|
356
357
|
minClientVersion: config.minClientVersion ?? '',
|
|
357
358
|
tokenStore: config.tokenStore,
|
|
@@ -370,7 +371,7 @@ class ApiServer {
|
|
|
370
371
|
this.config = fillConfig(config);
|
|
371
372
|
this.apiBasePath = this.normalizeApiBasePath(this.config.apiBasePath);
|
|
372
373
|
this.startedAt = Date.now();
|
|
373
|
-
this.storageAdapter = storage_js_1.
|
|
374
|
+
this.storageAdapter = storage_js_1.nullAuthAdapter;
|
|
374
375
|
this.moduleAdapter = module_js_1.nullAuthModule;
|
|
375
376
|
this.jwtHelper = new JwtHelperStore();
|
|
376
377
|
this.tokenStoreAdapter = this.config.tokenStore ?? null;
|
|
@@ -448,13 +449,19 @@ class ApiServer {
|
|
|
448
449
|
}
|
|
449
450
|
return this.passkeyServiceAdapter;
|
|
450
451
|
}
|
|
452
|
+
async listUserCredentials(userId) {
|
|
453
|
+
return this.ensurePasskeyService().listUserCredentials(userId);
|
|
454
|
+
}
|
|
455
|
+
async deletePasskeyCredential(credentialId) {
|
|
456
|
+
return this.ensurePasskeyService().deleteCredential(credentialId);
|
|
457
|
+
}
|
|
451
458
|
ensureOAuthStore() {
|
|
452
459
|
if (!this.oauthStoreAdapter) {
|
|
453
460
|
throw new Error('OAuth store is not configured');
|
|
454
461
|
}
|
|
455
462
|
return this.oauthStoreAdapter;
|
|
456
463
|
}
|
|
457
|
-
//
|
|
464
|
+
// AuthAdapter-compatible helpers (used by AuthModule)
|
|
458
465
|
async getUser(identifier) {
|
|
459
466
|
return this.userStoreAdapter ? this.userStoreAdapter.findUser(identifier) : null;
|
|
460
467
|
}
|
|
@@ -612,6 +619,7 @@ class ApiServer {
|
|
|
612
619
|
const path = `${this.apiBasePath}/v1/ping`;
|
|
613
620
|
this.app.get(path, (_req, res) => {
|
|
614
621
|
const payload = {
|
|
622
|
+
success: true,
|
|
615
623
|
status: 'ok',
|
|
616
624
|
apiVersion: this.config.apiVersion ?? '',
|
|
617
625
|
minClientVersion: this.config.minClientVersion ?? '',
|
|
@@ -619,7 +627,7 @@ class ApiServer {
|
|
|
619
627
|
startedAt: this.startedAt,
|
|
620
628
|
timestamp: new Date().toISOString()
|
|
621
629
|
};
|
|
622
|
-
res.status(200).json({ code: 200, message: 'Success', data: payload });
|
|
630
|
+
res.status(200).json({ success: true, code: 200, message: 'Success', data: payload, errors: {} });
|
|
623
631
|
});
|
|
624
632
|
}
|
|
625
633
|
normalizeApiBasePath(path) {
|
|
@@ -642,6 +650,7 @@ class ApiServer {
|
|
|
642
650
|
}
|
|
643
651
|
this.apiNotFoundHandler = (req, res) => {
|
|
644
652
|
const payload = {
|
|
653
|
+
success: false,
|
|
645
654
|
code: 404,
|
|
646
655
|
message: this.describeMissingEndpoint(req),
|
|
647
656
|
data: null,
|
|
@@ -700,16 +709,117 @@ class ApiServer {
|
|
|
700
709
|
}
|
|
701
710
|
async verifyJWT(token) {
|
|
702
711
|
if (!this.config.accessSecret) {
|
|
703
|
-
return { tokenData: undefined, error: 'JWT authentication disabled; no jwtSecret set' };
|
|
712
|
+
return { tokenData: undefined, error: 'JWT authentication disabled; no jwtSecret set', expired: false };
|
|
704
713
|
}
|
|
705
714
|
const result = this.jwtVerify(token, this.config.accessSecret);
|
|
706
715
|
if (!result.success) {
|
|
707
|
-
return { tokenData: undefined, error: result.error };
|
|
716
|
+
return { tokenData: undefined, error: result.error, expired: result.expired };
|
|
708
717
|
}
|
|
709
718
|
if (!result.data.uid) {
|
|
710
|
-
return { tokenData: undefined, error: 'Missing/bad userid in token' };
|
|
719
|
+
return { tokenData: undefined, error: 'Missing/bad userid in token', expired: false };
|
|
720
|
+
}
|
|
721
|
+
return { tokenData: result.data, error: undefined, expired: false };
|
|
722
|
+
}
|
|
723
|
+
jwtCookieOptions(apiReq) {
|
|
724
|
+
const conf = this.config;
|
|
725
|
+
const forwarded = apiReq.req.headers['x-forwarded-proto'];
|
|
726
|
+
const referer = apiReq.req.headers['origin'] ?? apiReq.req.headers['referer'];
|
|
727
|
+
const origin = typeof referer === 'string' ? referer : '';
|
|
728
|
+
const isHttps = forwarded === 'https' || apiReq.req.protocol === 'https';
|
|
729
|
+
const isLocalhost = origin.includes('localhost');
|
|
730
|
+
const options = {
|
|
731
|
+
httpOnly: true,
|
|
732
|
+
secure: true,
|
|
733
|
+
sameSite: 'strict',
|
|
734
|
+
domain: conf.cookieDomain || undefined,
|
|
735
|
+
path: '/',
|
|
736
|
+
maxAge: undefined
|
|
737
|
+
};
|
|
738
|
+
if (conf.devMode) {
|
|
739
|
+
options.secure = isHttps;
|
|
740
|
+
options.httpOnly = false;
|
|
741
|
+
options.sameSite = 'lax';
|
|
742
|
+
if (isLocalhost) {
|
|
743
|
+
options.domain = undefined;
|
|
744
|
+
}
|
|
711
745
|
}
|
|
712
|
-
return
|
|
746
|
+
return options;
|
|
747
|
+
}
|
|
748
|
+
setAccessCookie(apiReq, accessToken, sessionCookie) {
|
|
749
|
+
const conf = this.config;
|
|
750
|
+
const options = this.jwtCookieOptions(apiReq);
|
|
751
|
+
const accessMaxAge = Math.max(1, conf.accessExpiry) * 1000;
|
|
752
|
+
const accessOptions = sessionCookie ? options : { ...options, maxAge: accessMaxAge };
|
|
753
|
+
apiReq.res.cookie(conf.accessCookie, accessToken, accessOptions);
|
|
754
|
+
}
|
|
755
|
+
async tryRefreshAccessToken(apiReq) {
|
|
756
|
+
const conf = this.config;
|
|
757
|
+
if (!conf.refreshSecret || !conf.accessSecret) {
|
|
758
|
+
return null;
|
|
759
|
+
}
|
|
760
|
+
const rawRefresh = apiReq.req.cookies?.[conf.refreshCookie];
|
|
761
|
+
if (typeof rawRefresh !== 'string') {
|
|
762
|
+
return null;
|
|
763
|
+
}
|
|
764
|
+
const refreshToken = rawRefresh.trim();
|
|
765
|
+
if (!refreshToken) {
|
|
766
|
+
return null;
|
|
767
|
+
}
|
|
768
|
+
const verify = this.jwtVerify(refreshToken, conf.refreshSecret);
|
|
769
|
+
if (!verify.success || !verify.data) {
|
|
770
|
+
return null;
|
|
771
|
+
}
|
|
772
|
+
let stored = null;
|
|
773
|
+
try {
|
|
774
|
+
stored = await this.storageAdapter.getToken({ refreshToken });
|
|
775
|
+
}
|
|
776
|
+
catch {
|
|
777
|
+
return null;
|
|
778
|
+
}
|
|
779
|
+
if (!stored) {
|
|
780
|
+
return null;
|
|
781
|
+
}
|
|
782
|
+
const storedUid = String(stored.userId);
|
|
783
|
+
const verifyUid = verify.data.uid === undefined || verify.data.uid === null ? null : String(verify.data.uid);
|
|
784
|
+
if (verifyUid && verifyUid !== storedUid) {
|
|
785
|
+
return null;
|
|
786
|
+
}
|
|
787
|
+
const claims = verify.data;
|
|
788
|
+
const { exp: _exp, iat: _iat, nbf: _nbf, ...payload } = claims;
|
|
789
|
+
void _exp;
|
|
790
|
+
void _iat;
|
|
791
|
+
void _nbf;
|
|
792
|
+
// Ensure we never embed token secrets into refreshed access tokens.
|
|
793
|
+
delete payload.accessToken;
|
|
794
|
+
delete payload.refreshToken;
|
|
795
|
+
delete payload.userId;
|
|
796
|
+
delete payload.expires;
|
|
797
|
+
delete payload.issuedAt;
|
|
798
|
+
delete payload.lastSeenAt;
|
|
799
|
+
delete payload.status;
|
|
800
|
+
const access = this.jwtSign(payload, conf.accessSecret, conf.accessExpiry);
|
|
801
|
+
if (!access.success || !access.token) {
|
|
802
|
+
return null;
|
|
803
|
+
}
|
|
804
|
+
const updated = await this.updateToken({
|
|
805
|
+
refreshToken,
|
|
806
|
+
accessToken: access.token,
|
|
807
|
+
lastSeenAt: new Date()
|
|
808
|
+
});
|
|
809
|
+
if (!updated) {
|
|
810
|
+
return null;
|
|
811
|
+
}
|
|
812
|
+
this.setAccessCookie(apiReq, access.token, stored.sessionCookie ?? false);
|
|
813
|
+
if (apiReq.req.cookies) {
|
|
814
|
+
apiReq.req.cookies[conf.accessCookie] = access.token;
|
|
815
|
+
}
|
|
816
|
+
const verifiedAccess = await this.verifyJWT(access.token);
|
|
817
|
+
if (!verifiedAccess.tokenData) {
|
|
818
|
+
return null;
|
|
819
|
+
}
|
|
820
|
+
const refreshedStored = { ...stored, accessToken: access.token };
|
|
821
|
+
apiReq.authToken = refreshedStored;
|
|
822
|
+
return { token: access.token, tokenData: verifiedAccess.tokenData, stored: refreshedStored };
|
|
713
823
|
}
|
|
714
824
|
async authenticate(apiReq, authType) {
|
|
715
825
|
if (authType === 'none') {
|
|
@@ -719,6 +829,7 @@ class ApiServer {
|
|
|
719
829
|
let token = null;
|
|
720
830
|
const authHeader = apiReq.req.headers.authorization;
|
|
721
831
|
const requiresAuthToken = this.requiresAuthToken(authType);
|
|
832
|
+
const allowRefresh = requiresAuthToken || (authType === 'maybe' && this.config.refreshMaybe);
|
|
722
833
|
const apiKeyAuth = await this.tryAuthenticateApiKey(apiReq, authType, authHeader);
|
|
723
834
|
if (apiKeyAuth) {
|
|
724
835
|
return apiKeyAuth;
|
|
@@ -727,32 +838,84 @@ class ApiServer {
|
|
|
727
838
|
token = authHeader.slice(7).trim();
|
|
728
839
|
}
|
|
729
840
|
if (!token) {
|
|
730
|
-
const access = apiReq.req.cookies?.
|
|
841
|
+
const access = apiReq.req.cookies?.[this.config.accessCookie];
|
|
731
842
|
if (access) {
|
|
732
843
|
token = access;
|
|
733
844
|
}
|
|
734
845
|
}
|
|
846
|
+
let tokenData;
|
|
847
|
+
let error;
|
|
848
|
+
let expired = false;
|
|
735
849
|
if (!token || token === null) {
|
|
736
|
-
if (
|
|
737
|
-
|
|
850
|
+
if (authType === 'maybe') {
|
|
851
|
+
if (!this.config.refreshMaybe) {
|
|
852
|
+
return null;
|
|
853
|
+
}
|
|
854
|
+
const refreshed = await this.tryRefreshAccessToken(apiReq);
|
|
855
|
+
if (!refreshed) {
|
|
856
|
+
return null;
|
|
857
|
+
}
|
|
858
|
+
token = refreshed.token;
|
|
859
|
+
tokenData = refreshed.tokenData;
|
|
860
|
+
error = undefined;
|
|
861
|
+
expired = false;
|
|
862
|
+
}
|
|
863
|
+
else if (requiresAuthToken) {
|
|
864
|
+
const refreshed = await this.tryRefreshAccessToken(apiReq);
|
|
865
|
+
if (!refreshed) {
|
|
866
|
+
throw new ApiError({ code: 401, message: 'Authorization token is required (Bearer/cookie)' });
|
|
867
|
+
}
|
|
868
|
+
token = refreshed.token;
|
|
869
|
+
tokenData = refreshed.tokenData;
|
|
870
|
+
error = undefined;
|
|
871
|
+
expired = false;
|
|
738
872
|
}
|
|
739
873
|
}
|
|
740
874
|
if (!token) {
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
875
|
+
throw new ApiError({ code: 401, message: 'Unauthorized Access - requires authentication' });
|
|
876
|
+
}
|
|
877
|
+
if (!tokenData) {
|
|
878
|
+
const verified = await this.verifyJWT(token);
|
|
879
|
+
tokenData = verified.tokenData;
|
|
880
|
+
error = verified.error;
|
|
881
|
+
expired = verified.expired ?? false;
|
|
882
|
+
}
|
|
883
|
+
if (!tokenData && allowRefresh && expired) {
|
|
884
|
+
const refreshed = await this.tryRefreshAccessToken(apiReq);
|
|
885
|
+
if (refreshed) {
|
|
886
|
+
token = refreshed.token;
|
|
887
|
+
tokenData = refreshed.tokenData;
|
|
888
|
+
error = undefined;
|
|
746
889
|
}
|
|
747
890
|
}
|
|
748
|
-
const { tokenData, error } = await this.verifyJWT(token);
|
|
749
891
|
if (!tokenData) {
|
|
750
892
|
throw new ApiError({ code: 401, message: 'Unathorized Access - ' + error });
|
|
751
893
|
}
|
|
752
894
|
const effectiveUserId = this.extractTokenUserId(tokenData);
|
|
753
895
|
apiReq.realUid = this.resolveRealUserId(tokenData, effectiveUserId);
|
|
754
896
|
if (this.shouldValidateStoredToken(authType)) {
|
|
755
|
-
|
|
897
|
+
try {
|
|
898
|
+
await this.assertStoredAccessToken(apiReq, token, tokenData);
|
|
899
|
+
}
|
|
900
|
+
catch (error) {
|
|
901
|
+
if (allowRefresh &&
|
|
902
|
+
error instanceof ApiError &&
|
|
903
|
+
error.code === 401 &&
|
|
904
|
+
error.message === 'Authorization token is no longer valid') {
|
|
905
|
+
const refreshed = await this.tryRefreshAccessToken(apiReq);
|
|
906
|
+
if (!refreshed) {
|
|
907
|
+
throw error;
|
|
908
|
+
}
|
|
909
|
+
token = refreshed.token;
|
|
910
|
+
tokenData = refreshed.tokenData;
|
|
911
|
+
const refreshedUserId = this.extractTokenUserId(tokenData);
|
|
912
|
+
apiReq.realUid = this.resolveRealUserId(tokenData, refreshedUserId);
|
|
913
|
+
await this.assertStoredAccessToken(apiReq, token, tokenData);
|
|
914
|
+
}
|
|
915
|
+
else {
|
|
916
|
+
throw error;
|
|
917
|
+
}
|
|
918
|
+
}
|
|
756
919
|
}
|
|
757
920
|
apiReq.token = token;
|
|
758
921
|
return tokenData;
|
|
@@ -794,6 +957,9 @@ class ApiServer {
|
|
|
794
957
|
}
|
|
795
958
|
async assertStoredAccessToken(apiReq, token, tokenData) {
|
|
796
959
|
const userId = String(this.extractTokenUserId(tokenData));
|
|
960
|
+
if (apiReq.authToken && apiReq.authToken.accessToken === token && String(apiReq.authToken.userId) === userId) {
|
|
961
|
+
return;
|
|
962
|
+
}
|
|
797
963
|
const stored = await this.storageAdapter.getToken({
|
|
798
964
|
accessToken: token,
|
|
799
965
|
userId
|
|
@@ -833,32 +999,100 @@ class ApiServer {
|
|
|
833
999
|
}
|
|
834
1000
|
return rawReal;
|
|
835
1001
|
}
|
|
836
|
-
|
|
1002
|
+
useExpress(pathOrHandler, ...handlers) {
|
|
1003
|
+
if (typeof pathOrHandler === 'string') {
|
|
1004
|
+
this.app.use(pathOrHandler, ...handlers);
|
|
1005
|
+
}
|
|
1006
|
+
else {
|
|
1007
|
+
this.app.use(pathOrHandler, ...handlers);
|
|
1008
|
+
}
|
|
1009
|
+
this.ensureApiNotFoundOrdering();
|
|
1010
|
+
return this;
|
|
1011
|
+
}
|
|
1012
|
+
createApiRequest(req, res) {
|
|
1013
|
+
const apiReq = {
|
|
1014
|
+
server: this,
|
|
1015
|
+
req,
|
|
1016
|
+
res,
|
|
1017
|
+
token: '',
|
|
1018
|
+
tokenData: null,
|
|
1019
|
+
realUid: null,
|
|
1020
|
+
getClientInfo: () => ensureClientInfo(apiReq),
|
|
1021
|
+
getClientIp: () => ensureClientInfo(apiReq).ip,
|
|
1022
|
+
getClientIpChain: () => ensureClientInfo(apiReq).ipchain,
|
|
1023
|
+
getRealUid: () => apiReq.realUid ?? null,
|
|
1024
|
+
isImpersonating: () => {
|
|
1025
|
+
const realUid = apiReq.realUid;
|
|
1026
|
+
const tokenUid = apiReq.tokenData?.uid;
|
|
1027
|
+
if (realUid === null || realUid === undefined) {
|
|
1028
|
+
return false;
|
|
1029
|
+
}
|
|
1030
|
+
if (tokenUid === null || tokenUid === undefined) {
|
|
1031
|
+
return false;
|
|
1032
|
+
}
|
|
1033
|
+
return realUid !== tokenUid;
|
|
1034
|
+
}
|
|
1035
|
+
};
|
|
1036
|
+
return apiReq;
|
|
1037
|
+
}
|
|
1038
|
+
expressAuth(auth) {
|
|
837
1039
|
return async (req, res, next) => {
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
getClientIpChain: () => ensureClientInfo(apiReq).ipchain,
|
|
849
|
-
getRealUid: () => apiReq.realUid ?? null,
|
|
850
|
-
isImpersonating: () => {
|
|
851
|
-
const realUid = apiReq.realUid;
|
|
852
|
-
const tokenUid = apiReq.tokenData?.uid;
|
|
853
|
-
if (realUid === null || realUid === undefined) {
|
|
854
|
-
return false;
|
|
855
|
-
}
|
|
856
|
-
if (tokenUid === null || tokenUid === undefined) {
|
|
857
|
-
return false;
|
|
858
|
-
}
|
|
859
|
-
return realUid !== tokenUid;
|
|
1040
|
+
const apiReq = this.createApiRequest(req, res);
|
|
1041
|
+
req.apiReq = apiReq;
|
|
1042
|
+
res.locals.apiReq = apiReq;
|
|
1043
|
+
this.currReq = apiReq;
|
|
1044
|
+
try {
|
|
1045
|
+
if (this.config.hydrateGetBody) {
|
|
1046
|
+
hydrateGetBody(req);
|
|
1047
|
+
}
|
|
1048
|
+
if (this.config.debug) {
|
|
1049
|
+
this.dumpRequest(apiReq);
|
|
860
1050
|
}
|
|
1051
|
+
apiReq.tokenData = await this.authenticate(apiReq, auth.type);
|
|
1052
|
+
await this.authorize(apiReq, auth.req);
|
|
1053
|
+
next();
|
|
1054
|
+
}
|
|
1055
|
+
catch (error) {
|
|
1056
|
+
next(error);
|
|
1057
|
+
}
|
|
1058
|
+
};
|
|
1059
|
+
}
|
|
1060
|
+
expressErrorHandler() {
|
|
1061
|
+
return (error, _req, res, next) => {
|
|
1062
|
+
void _req;
|
|
1063
|
+
if (res.headersSent) {
|
|
1064
|
+
next(error);
|
|
1065
|
+
return;
|
|
1066
|
+
}
|
|
1067
|
+
if (error instanceof ApiError || isApiErrorLike(error)) {
|
|
1068
|
+
const apiError = error;
|
|
1069
|
+
const normalizedErrors = apiError.errors && typeof apiError.errors === 'object' && !Array.isArray(apiError.errors)
|
|
1070
|
+
? apiError.errors
|
|
1071
|
+
: {};
|
|
1072
|
+
const errorPayload = {
|
|
1073
|
+
success: false,
|
|
1074
|
+
code: apiError.code,
|
|
1075
|
+
message: apiError.message,
|
|
1076
|
+
data: apiError.data ?? null,
|
|
1077
|
+
errors: normalizedErrors
|
|
1078
|
+
};
|
|
1079
|
+
res.status(apiError.code).json(errorPayload);
|
|
1080
|
+
return;
|
|
1081
|
+
}
|
|
1082
|
+
const errorPayload = {
|
|
1083
|
+
success: false,
|
|
1084
|
+
code: 500,
|
|
1085
|
+
message: this.guessExceptionText(error),
|
|
1086
|
+
data: null,
|
|
1087
|
+
errors: {}
|
|
861
1088
|
};
|
|
1089
|
+
res.status(500).json(errorPayload);
|
|
1090
|
+
};
|
|
1091
|
+
}
|
|
1092
|
+
handle_request(handler, auth) {
|
|
1093
|
+
return async (req, res, next) => {
|
|
1094
|
+
void next;
|
|
1095
|
+
const apiReq = this.createApiRequest(req, res);
|
|
862
1096
|
this.currReq = apiReq;
|
|
863
1097
|
try {
|
|
864
1098
|
if (this.config.hydrateGetBody) {
|
|
@@ -881,7 +1115,7 @@ class ApiServer {
|
|
|
881
1115
|
throw new ApiError({ code: 500, message: 'Handler result must start with a numeric status code' });
|
|
882
1116
|
}
|
|
883
1117
|
const message = typeof rawMessage === 'string' ? rawMessage : 'Success';
|
|
884
|
-
const responsePayload = { code, message, data };
|
|
1118
|
+
const responsePayload = { success: true, code, message, data, errors: {} };
|
|
885
1119
|
if (this.config.debug) {
|
|
886
1120
|
this.dumpResponse(apiReq, responsePayload, code);
|
|
887
1121
|
}
|
|
@@ -894,6 +1128,7 @@ class ApiServer {
|
|
|
894
1128
|
? apiError.errors
|
|
895
1129
|
: {};
|
|
896
1130
|
const errorPayload = {
|
|
1131
|
+
success: false,
|
|
897
1132
|
code: apiError.code,
|
|
898
1133
|
message: apiError.message,
|
|
899
1134
|
data: apiError.data ?? null,
|
|
@@ -906,6 +1141,7 @@ class ApiServer {
|
|
|
906
1141
|
}
|
|
907
1142
|
else {
|
|
908
1143
|
const errorPayload = {
|
|
1144
|
+
success: false,
|
|
909
1145
|
code: 500,
|
|
910
1146
|
message: this.guessExceptionText(error),
|
|
911
1147
|
data: null,
|
|
@@ -4,16 +4,16 @@
|
|
|
4
4
|
* This source code is licensed under the MIT license found in the
|
|
5
5
|
* LICENSE file in the root directory of this source tree.
|
|
6
6
|
*/
|
|
7
|
-
import { Application, Request, Response } from 'express';
|
|
7
|
+
import { Application, Request, Response, type ErrorRequestHandler, type RequestHandler } from 'express';
|
|
8
8
|
import { ApiModule } from './api-module.js';
|
|
9
9
|
import { TokenStore, type JwtDecodeResult, type JwtSignResult, type JwtVerifyResult } from './token/base.js';
|
|
10
|
-
import type { ApiAuthClass, ApiKey } from './api-module.js';
|
|
10
|
+
import type { ApiAuthClass, ApiAuthType, ApiKey } from './api-module.js';
|
|
11
11
|
import type { AuthProviderModule } from './auth-api/module.js';
|
|
12
|
-
import type {
|
|
12
|
+
import type { AuthAdapter, AuthIdentifier } from './auth-api/types.js';
|
|
13
13
|
import type { OAuthStore } from './oauth/base.js';
|
|
14
14
|
import type { AuthCodeData, AuthCodeRequest, OAuthClient } from './oauth/types.js';
|
|
15
15
|
import type { PasskeyService } from './passkey/service.js';
|
|
16
|
-
import type { PasskeyChallenge, PasskeyChallengeParams, PasskeyVerificationParams, PasskeyVerificationResult } from './passkey/types.js';
|
|
16
|
+
import type { PasskeyChallenge, PasskeyChallengeParams, StoredPasskeyCredential, PasskeyVerificationParams, PasskeyVerificationResult } from './passkey/types.js';
|
|
17
17
|
import type { Token } from './token/types.js';
|
|
18
18
|
import type { UserStore } from './user/base.js';
|
|
19
19
|
import type { JwtPayload, SignOptions, VerifyOptions } from 'jsonwebtoken';
|
|
@@ -47,6 +47,12 @@ export interface ApiRequest {
|
|
|
47
47
|
getRealUid: () => AuthIdentifier | null;
|
|
48
48
|
isImpersonating: () => boolean;
|
|
49
49
|
}
|
|
50
|
+
export interface ExpressApiRequest extends ExtendedReq {
|
|
51
|
+
apiReq?: ApiRequest;
|
|
52
|
+
}
|
|
53
|
+
export interface ExpressApiLocals {
|
|
54
|
+
apiReq?: ApiRequest;
|
|
55
|
+
}
|
|
50
56
|
export interface ClientAgentProfile {
|
|
51
57
|
ua: string;
|
|
52
58
|
browser: string;
|
|
@@ -101,6 +107,7 @@ export interface ApiServerConf {
|
|
|
101
107
|
devMode: boolean;
|
|
102
108
|
hydrateGetBody: boolean;
|
|
103
109
|
validateTokens: boolean;
|
|
110
|
+
refreshMaybe: boolean;
|
|
104
111
|
apiVersion: string;
|
|
105
112
|
minClientVersion: string;
|
|
106
113
|
tokenStore?: TokenStore;
|
|
@@ -122,23 +129,25 @@ export declare class ApiServer {
|
|
|
122
129
|
private canImpersonateAdapter;
|
|
123
130
|
private readonly jwtHelper;
|
|
124
131
|
constructor(config?: Partial<ApiServerConf>);
|
|
125
|
-
authStorage<UserRow, SafeUser>(storage:
|
|
132
|
+
authStorage<UserRow, SafeUser>(storage: AuthAdapter<UserRow, SafeUser>): this;
|
|
126
133
|
/**
|
|
127
134
|
* @deprecated Use {@link ApiServer.authStorage} instead.
|
|
128
135
|
*/
|
|
129
|
-
useAuthStorage<UserRow, SafeUser>(storage:
|
|
136
|
+
useAuthStorage<UserRow, SafeUser>(storage: AuthAdapter<UserRow, SafeUser>): this;
|
|
130
137
|
authModule<UserRow>(module: AuthProviderModule<UserRow>): this;
|
|
131
138
|
/**
|
|
132
139
|
* @deprecated Use {@link ApiServer.authModule} instead.
|
|
133
140
|
*/
|
|
134
141
|
useAuthModule<UserRow>(module: AuthProviderModule<UserRow>): this;
|
|
135
|
-
getAuthStorage():
|
|
142
|
+
getAuthStorage(): AuthAdapter<any, any>;
|
|
136
143
|
getAuthModule(): AuthProviderModule<any>;
|
|
137
144
|
setTokenStore(store: TokenStore): this;
|
|
138
145
|
getTokenStore(): TokenStore | null;
|
|
139
146
|
private ensureUserStore;
|
|
140
147
|
private ensureTokenStore;
|
|
141
148
|
private ensurePasskeyService;
|
|
149
|
+
listUserCredentials(userId: AuthIdentifier): Promise<StoredPasskeyCredential[]>;
|
|
150
|
+
deletePasskeyCredential(credentialId: Buffer | string): Promise<boolean>;
|
|
142
151
|
private ensureOAuthStore;
|
|
143
152
|
getUser(identifier: AuthIdentifier): Promise<any | null>;
|
|
144
153
|
getUserPasswordHash(user: any): string;
|
|
@@ -187,6 +196,9 @@ export declare class ApiServer {
|
|
|
187
196
|
private describeMissingEndpoint;
|
|
188
197
|
start(): this;
|
|
189
198
|
private verifyJWT;
|
|
199
|
+
private jwtCookieOptions;
|
|
200
|
+
private setAccessCookie;
|
|
201
|
+
private tryRefreshAccessToken;
|
|
190
202
|
private authenticate;
|
|
191
203
|
private tryAuthenticateApiKey;
|
|
192
204
|
private requiresAuthToken;
|
|
@@ -195,6 +207,14 @@ export declare class ApiServer {
|
|
|
195
207
|
private normalizeAuthIdentifier;
|
|
196
208
|
private extractTokenUserId;
|
|
197
209
|
private resolveRealUserId;
|
|
210
|
+
useExpress(path: string, ...handlers: Array<RequestHandler | ErrorRequestHandler>): this;
|
|
211
|
+
useExpress(...handlers: Array<RequestHandler | ErrorRequestHandler>): this;
|
|
212
|
+
private createApiRequest;
|
|
213
|
+
expressAuth(auth: {
|
|
214
|
+
type: ApiAuthType;
|
|
215
|
+
req: ApiAuthClass;
|
|
216
|
+
}): RequestHandler;
|
|
217
|
+
expressErrorHandler(): ErrorRequestHandler;
|
|
198
218
|
private handle_request;
|
|
199
219
|
api<T extends ApiModule<any>>(module: T): this;
|
|
200
220
|
dumpRequest(apiReq: ApiRequest): void;
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { type ApiRequest, type ApiRoute, type ApiServer } from '../api-server-base.js';
|
|
2
2
|
import { BaseAuthModule, type AuthProviderModule } from './module.js';
|
|
3
|
-
import type {
|
|
3
|
+
import type { AuthAdapter, AuthIdentifier } from './types.js';
|
|
4
4
|
import type { OAuthCallbackParams, OAuthCallbackResult, OAuthStartParams, OAuthStartResult } from '../oauth/types.js';
|
|
5
5
|
import type { TokenPair, Token } from '../token/types.js';
|
|
6
6
|
interface CanImpersonateContext<UserEntity> {
|
|
@@ -46,7 +46,7 @@ export default class AuthModule<UserEntity, PublicUser> extends BaseAuthModule<U
|
|
|
46
46
|
private readonly defaultDomain?;
|
|
47
47
|
private readonly canImpersonateHook?;
|
|
48
48
|
constructor(options?: AuthModuleOptions<UserEntity>);
|
|
49
|
-
protected get storage():
|
|
49
|
+
protected get storage(): AuthAdapter<UserEntity, PublicUser>;
|
|
50
50
|
protected canImpersonate(apiReq: ApiRequest, realUser: UserEntity, targetUser: UserEntity): Promise<boolean>;
|
|
51
51
|
protected ensureImpersonationAllowed(apiReq: ApiRequest, realUser: UserEntity, targetUser: UserEntity): Promise<void>;
|
|
52
52
|
protected buildTokenPayload(user: UserEntity, metadata?: TokenMetadata): TokenClaims;
|
|
@@ -57,6 +57,9 @@ export default class AuthModule<UserEntity, PublicUser> extends BaseAuthModule<U
|
|
|
57
57
|
private resolveSessionPreferences;
|
|
58
58
|
private mergeSessionPreferences;
|
|
59
59
|
private sessionPrefsFromRecord;
|
|
60
|
+
private validateCredentialId;
|
|
61
|
+
private normalizeCredentialId;
|
|
62
|
+
private toIsoDate;
|
|
60
63
|
private cookieOptions;
|
|
61
64
|
private setJwtCookies;
|
|
62
65
|
issueTokens(apiReq: ApiRequest, user: UserEntity, metadata?: TokenIssueOptions): Promise<TokenPair>;
|
|
@@ -76,6 +79,8 @@ export default class AuthModule<UserEntity, PublicUser> extends BaseAuthModule<U
|
|
|
76
79
|
private postWhoAmI;
|
|
77
80
|
private postPasskeyChallenge;
|
|
78
81
|
private postPasskeyVerify;
|
|
82
|
+
private getPasskeys;
|
|
83
|
+
private deletePasskey;
|
|
79
84
|
private postImpersonation;
|
|
80
85
|
private deleteImpersonation;
|
|
81
86
|
private getUserFromPasskey;
|
|
@@ -91,6 +96,10 @@ export default class AuthModule<UserEntity, PublicUser> extends BaseAuthModule<U
|
|
|
91
96
|
private resolveClientAuthentication;
|
|
92
97
|
private assertRedirectUriAllowed;
|
|
93
98
|
private resolveUserForOAuth;
|
|
99
|
+
private hasPasskeyService;
|
|
100
|
+
private hasOAuthStore;
|
|
101
|
+
private storageImplements;
|
|
102
|
+
private storageImplementsAll;
|
|
94
103
|
defineRoutes(): ApiRoute[];
|
|
95
104
|
}
|
|
96
105
|
export {};
|