@technomoron/api-server-base 2.0.0-beta.6 → 2.0.0-beta.9
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 +23 -1
- package/dist/cjs/api-server-base.cjs +90 -24
- package/dist/cjs/api-server-base.d.ts +16 -2
- package/dist/cjs/index.d.ts +1 -1
- package/dist/cjs/passkey/service.js +95 -7
- package/dist/esm/api-server-base.d.ts +16 -2
- package/dist/esm/api-server-base.js +90 -24
- package/dist/esm/index.d.ts +1 -1
- package/dist/esm/passkey/service.js +63 -8
- package/package.json +1 -1
package/README.txt
CHANGED
|
@@ -122,7 +122,7 @@ Request Lifecycle
|
|
|
122
122
|
-----------------
|
|
123
123
|
1. Express middlewares (express.json, cookie-parser, optional multer) run before your handler.
|
|
124
124
|
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`
|
|
125
|
+
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`. 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
126
|
4. authorize runs with the requested auth class (any or admin in the base implementation). Override to connect to your role system.
|
|
127
127
|
5. The handler executes and returns its tuple. Responses are normalized to { code, message, data } JSON.
|
|
128
128
|
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 +140,28 @@ Use your storage adapter's filterUser helper to trim sensitive data before retur
|
|
|
140
140
|
Provide your own authorize method to enforce role based access control using the ApiAuthClass enum.
|
|
141
141
|
Create feature modules by extending ApiModule. Use the optional checkConfig hook to validate prerequisites before routes mount.
|
|
142
142
|
|
|
143
|
+
Custom Express Endpoints
|
|
144
|
+
------------------------
|
|
145
|
+
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.
|
|
146
|
+
|
|
147
|
+
- `server.useExpress(...)` mounts middleware/routes and keeps the built-in `/api` 404 handler ordered last, so mounts under `apiBasePath` are not intercepted.
|
|
148
|
+
- 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`.
|
|
149
|
+
- On success, `expressAuth` attaches the computed ApiRequest to both `req.apiReq` and `res.locals.apiReq`.
|
|
150
|
+
- If you want the same JSON error envelope for custom endpoints, mount `server.expressErrorHandler()` after your custom routes.
|
|
151
|
+
|
|
152
|
+
Example:
|
|
153
|
+
|
|
154
|
+
server
|
|
155
|
+
.useExpress(
|
|
156
|
+
'/api/custom/optional',
|
|
157
|
+
server.expressAuth({ type: 'maybe', req: 'any' }),
|
|
158
|
+
(req, res) => {
|
|
159
|
+
const apiReq = (req as any).apiReq;
|
|
160
|
+
res.status(200).json({ uid: apiReq.tokenData?.uid ?? null });
|
|
161
|
+
}
|
|
162
|
+
)
|
|
163
|
+
.useExpress(server.expressErrorHandler());
|
|
164
|
+
|
|
143
165
|
|
|
144
166
|
Tooling and Scripts
|
|
145
167
|
-------------------
|
|
@@ -733,7 +733,7 @@ class ApiServer {
|
|
|
733
733
|
token = authHeader.slice(7).trim();
|
|
734
734
|
}
|
|
735
735
|
if (!token) {
|
|
736
|
-
const access = apiReq.req.cookies?.
|
|
736
|
+
const access = apiReq.req.cookies?.[this.config.accessCookie];
|
|
737
737
|
if (access) {
|
|
738
738
|
token = access;
|
|
739
739
|
}
|
|
@@ -839,32 +839,98 @@ class ApiServer {
|
|
|
839
839
|
}
|
|
840
840
|
return rawReal;
|
|
841
841
|
}
|
|
842
|
-
|
|
842
|
+
useExpress(pathOrHandler, ...handlers) {
|
|
843
|
+
if (typeof pathOrHandler === 'string') {
|
|
844
|
+
this.app.use(pathOrHandler, ...handlers);
|
|
845
|
+
}
|
|
846
|
+
else {
|
|
847
|
+
this.app.use(pathOrHandler, ...handlers);
|
|
848
|
+
}
|
|
849
|
+
this.ensureApiNotFoundOrdering();
|
|
850
|
+
return this;
|
|
851
|
+
}
|
|
852
|
+
createApiRequest(req, res) {
|
|
853
|
+
const apiReq = {
|
|
854
|
+
server: this,
|
|
855
|
+
req,
|
|
856
|
+
res,
|
|
857
|
+
token: '',
|
|
858
|
+
tokenData: null,
|
|
859
|
+
realUid: null,
|
|
860
|
+
getClientInfo: () => ensureClientInfo(apiReq),
|
|
861
|
+
getClientIp: () => ensureClientInfo(apiReq).ip,
|
|
862
|
+
getClientIpChain: () => ensureClientInfo(apiReq).ipchain,
|
|
863
|
+
getRealUid: () => apiReq.realUid ?? null,
|
|
864
|
+
isImpersonating: () => {
|
|
865
|
+
const realUid = apiReq.realUid;
|
|
866
|
+
const tokenUid = apiReq.tokenData?.uid;
|
|
867
|
+
if (realUid === null || realUid === undefined) {
|
|
868
|
+
return false;
|
|
869
|
+
}
|
|
870
|
+
if (tokenUid === null || tokenUid === undefined) {
|
|
871
|
+
return false;
|
|
872
|
+
}
|
|
873
|
+
return realUid !== tokenUid;
|
|
874
|
+
}
|
|
875
|
+
};
|
|
876
|
+
return apiReq;
|
|
877
|
+
}
|
|
878
|
+
expressAuth(auth) {
|
|
843
879
|
return async (req, res, next) => {
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
getClientIpChain: () => ensureClientInfo(apiReq).ipchain,
|
|
855
|
-
getRealUid: () => apiReq.realUid ?? null,
|
|
856
|
-
isImpersonating: () => {
|
|
857
|
-
const realUid = apiReq.realUid;
|
|
858
|
-
const tokenUid = apiReq.tokenData?.uid;
|
|
859
|
-
if (realUid === null || realUid === undefined) {
|
|
860
|
-
return false;
|
|
861
|
-
}
|
|
862
|
-
if (tokenUid === null || tokenUid === undefined) {
|
|
863
|
-
return false;
|
|
864
|
-
}
|
|
865
|
-
return realUid !== tokenUid;
|
|
880
|
+
const apiReq = this.createApiRequest(req, res);
|
|
881
|
+
req.apiReq = apiReq;
|
|
882
|
+
res.locals.apiReq = apiReq;
|
|
883
|
+
this.currReq = apiReq;
|
|
884
|
+
try {
|
|
885
|
+
if (this.config.hydrateGetBody) {
|
|
886
|
+
hydrateGetBody(req);
|
|
887
|
+
}
|
|
888
|
+
if (this.config.debug) {
|
|
889
|
+
this.dumpRequest(apiReq);
|
|
866
890
|
}
|
|
891
|
+
apiReq.tokenData = await this.authenticate(apiReq, auth.type);
|
|
892
|
+
await this.authorize(apiReq, auth.req);
|
|
893
|
+
next();
|
|
894
|
+
}
|
|
895
|
+
catch (error) {
|
|
896
|
+
next(error);
|
|
897
|
+
}
|
|
898
|
+
};
|
|
899
|
+
}
|
|
900
|
+
expressErrorHandler() {
|
|
901
|
+
return (error, _req, res, next) => {
|
|
902
|
+
void _req;
|
|
903
|
+
if (res.headersSent) {
|
|
904
|
+
next(error);
|
|
905
|
+
return;
|
|
906
|
+
}
|
|
907
|
+
if (error instanceof ApiError || isApiErrorLike(error)) {
|
|
908
|
+
const apiError = error;
|
|
909
|
+
const normalizedErrors = apiError.errors && typeof apiError.errors === 'object' && !Array.isArray(apiError.errors)
|
|
910
|
+
? apiError.errors
|
|
911
|
+
: {};
|
|
912
|
+
const errorPayload = {
|
|
913
|
+
code: apiError.code,
|
|
914
|
+
message: apiError.message,
|
|
915
|
+
data: apiError.data ?? null,
|
|
916
|
+
errors: normalizedErrors
|
|
917
|
+
};
|
|
918
|
+
res.status(apiError.code).json(errorPayload);
|
|
919
|
+
return;
|
|
920
|
+
}
|
|
921
|
+
const errorPayload = {
|
|
922
|
+
code: 500,
|
|
923
|
+
message: this.guessExceptionText(error),
|
|
924
|
+
data: null,
|
|
925
|
+
errors: {}
|
|
867
926
|
};
|
|
927
|
+
res.status(500).json(errorPayload);
|
|
928
|
+
};
|
|
929
|
+
}
|
|
930
|
+
handle_request(handler, auth) {
|
|
931
|
+
return async (req, res, next) => {
|
|
932
|
+
void next;
|
|
933
|
+
const apiReq = this.createApiRequest(req, res);
|
|
868
934
|
this.currReq = apiReq;
|
|
869
935
|
try {
|
|
870
936
|
if (this.config.hydrateGetBody) {
|
|
@@ -4,10 +4,10 @@
|
|
|
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
12
|
import type { AuthAdapter, AuthIdentifier } from './auth-api/types.js';
|
|
13
13
|
import type { OAuthStore } from './oauth/base.js';
|
|
@@ -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;
|
|
@@ -197,6 +203,14 @@ export declare class ApiServer {
|
|
|
197
203
|
private normalizeAuthIdentifier;
|
|
198
204
|
private extractTokenUserId;
|
|
199
205
|
private resolveRealUserId;
|
|
206
|
+
useExpress(path: string, ...handlers: Array<RequestHandler | ErrorRequestHandler>): this;
|
|
207
|
+
useExpress(...handlers: Array<RequestHandler | ErrorRequestHandler>): this;
|
|
208
|
+
private createApiRequest;
|
|
209
|
+
expressAuth(auth: {
|
|
210
|
+
type: ApiAuthType;
|
|
211
|
+
req: ApiAuthClass;
|
|
212
|
+
}): RequestHandler;
|
|
213
|
+
expressErrorHandler(): ErrorRequestHandler;
|
|
200
214
|
private handle_request;
|
|
201
215
|
api<T extends ApiModule<any>>(module: T): this;
|
|
202
216
|
dumpRequest(apiReq: ApiRequest): void;
|
package/dist/cjs/index.d.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
export { default as ApiServer } from './api-server-base.js';
|
|
2
2
|
export { ApiError } from './api-server-base.js';
|
|
3
3
|
export { ApiModule } from './api-module.js';
|
|
4
|
-
export type { ApiErrorParams, ApiHandler, ApiKey, ApiServerConf, ApiRequest, ApiRoute, ApiAuthType, ApiAuthClass, ApiTokenData, ExtendedReq } from './api-server-base.js';
|
|
4
|
+
export type { ApiErrorParams, ApiHandler, ApiKey, ApiServerConf, ApiRequest, ApiRoute, ApiAuthType, ApiAuthClass, ApiTokenData, ExtendedReq, ExpressApiRequest, ExpressApiLocals } from './api-server-base.js';
|
|
5
5
|
export type { AuthIdentifier } from './auth-api/types.js';
|
|
6
6
|
export type { Token, TokenPair, TokenStatus } from './token/types.js';
|
|
7
7
|
export type { JwtSignResult, JwtVerifyResult, JwtDecodeResult } from './token/base.js';
|
|
@@ -1,4 +1,37 @@
|
|
|
1
1
|
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
2
35
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
36
|
exports.PasskeyService = void 0;
|
|
4
37
|
const server_1 = require("@simplewebauthn/server");
|
|
@@ -54,6 +87,32 @@ function toBufferOrNull(value) {
|
|
|
54
87
|
}
|
|
55
88
|
return null;
|
|
56
89
|
}
|
|
90
|
+
async function spkiToCosePublicKey(spki) {
|
|
91
|
+
try {
|
|
92
|
+
const subtle = globalThis.crypto?.subtle ?? (await Promise.resolve().then(() => __importStar(require('crypto')))).webcrypto?.subtle;
|
|
93
|
+
if (!subtle) {
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
const key = await subtle.importKey('spki', spki, { name: 'ECDSA', namedCurve: 'P-256' }, true, ['verify']);
|
|
97
|
+
const raw = Buffer.from(await subtle.exportKey('raw', key));
|
|
98
|
+
if (raw.length !== 65 || raw[0] !== 0x04) {
|
|
99
|
+
return null;
|
|
100
|
+
}
|
|
101
|
+
const x = raw.slice(1, 33);
|
|
102
|
+
const y = raw.slice(33, 65);
|
|
103
|
+
const coseMap = new Map([
|
|
104
|
+
[1, 2], // kty: EC2
|
|
105
|
+
[3, -7], // alg: ES256
|
|
106
|
+
[-1, 1], // crv: P-256
|
|
107
|
+
[-2, x],
|
|
108
|
+
[-3, y]
|
|
109
|
+
]);
|
|
110
|
+
return Buffer.from(helpers_1.isoCBOR.encode(coseMap));
|
|
111
|
+
}
|
|
112
|
+
catch {
|
|
113
|
+
return null;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
57
116
|
class PasskeyService {
|
|
58
117
|
constructor(config, adapter, logger = console) {
|
|
59
118
|
this.config = config;
|
|
@@ -195,18 +254,44 @@ class PasskeyService {
|
|
|
195
254
|
const credentialIdFallback = toBufferOrNull(params.response?.id);
|
|
196
255
|
const credentialId = credentialIdPrimary && credentialIdPrimary.length > 0 ? credentialIdPrimary : credentialIdFallback;
|
|
197
256
|
const publicKeyPrimary = toBufferOrNull(registrationInfo.credentialPublicKey);
|
|
198
|
-
|
|
257
|
+
let publicKeyFallback = toBufferOrNull(attestationResponse?.publicKey);
|
|
258
|
+
if ((!publicKeyPrimary || publicKeyPrimary.length === 0) && attestationResponse?.attestationObject) {
|
|
259
|
+
try {
|
|
260
|
+
const attObj = (0, helpers_1.decodeAttestationObject)(helpers_1.isoBase64URL.toBuffer(attestationResponse.attestationObject));
|
|
261
|
+
const parsedAuth = (0, helpers_1.parseAuthenticatorData)(attObj.get('authData'));
|
|
262
|
+
publicKeyFallback = toBufferOrNull(parsedAuth.credentialPublicKey) ?? publicKeyFallback;
|
|
263
|
+
}
|
|
264
|
+
catch (error) {
|
|
265
|
+
this.logger.warn?.('Passkey registration: failed to parse attestationObject', error);
|
|
266
|
+
}
|
|
267
|
+
}
|
|
199
268
|
const publicKey = publicKeyPrimary && publicKeyPrimary.length > 0 ? publicKeyPrimary : publicKeyFallback;
|
|
269
|
+
if (this.logger?.warn) {
|
|
270
|
+
const pkPrimaryHex = publicKeyPrimary ? publicKeyPrimary.slice(0, 4).toString('hex') : 'null';
|
|
271
|
+
const pkFallbackHex = publicKeyFallback ? publicKeyFallback.slice(0, 4).toString('hex') : 'null';
|
|
272
|
+
this.logger.warn(`Passkey registration: pkPrimary len=${publicKeyPrimary?.length ?? 0} head=${pkPrimaryHex}, pkFallback len=${publicKeyFallback?.length ?? 0} head=${pkFallbackHex}`);
|
|
273
|
+
}
|
|
200
274
|
if (!credentialId || credentialId.length === 0) {
|
|
201
275
|
return { verified: false, message: 'Missing credential id in registration response' };
|
|
202
276
|
}
|
|
203
277
|
if (!publicKey || publicKey.length === 0) {
|
|
204
278
|
return { verified: false, message: 'Missing public key in registration response' };
|
|
205
279
|
}
|
|
280
|
+
let storedPublicKey = Buffer.isBuffer(publicKey) ? publicKey : toBuffer(publicKey);
|
|
281
|
+
// If fallback is an SPKI key (DER, starts with 0x30) convert to COSE so verification succeeds.
|
|
282
|
+
if (storedPublicKey[0] === 0x30) {
|
|
283
|
+
const converted = await spkiToCosePublicKey(storedPublicKey);
|
|
284
|
+
if (converted) {
|
|
285
|
+
storedPublicKey = converted;
|
|
286
|
+
}
|
|
287
|
+
else {
|
|
288
|
+
this.logger.warn?.('Passkey registration: failed to convert SPKI public key to COSE');
|
|
289
|
+
}
|
|
290
|
+
}
|
|
206
291
|
await this.adapter.saveCredential({
|
|
207
292
|
userId: user.id,
|
|
208
293
|
credentialId,
|
|
209
|
-
publicKey,
|
|
294
|
+
publicKey: storedPublicKey,
|
|
210
295
|
counter: registrationInfo.counter ?? 0,
|
|
211
296
|
transports: sanitizeTransports(params.response.transports),
|
|
212
297
|
backedUp: registrationInfo.credentialDeviceType === 'multiDevice',
|
|
@@ -227,19 +312,22 @@ class PasskeyService {
|
|
|
227
312
|
}
|
|
228
313
|
const user = await this.requireUser({ userId: credential.userId, login: record.login });
|
|
229
314
|
const storedAuthData = {
|
|
230
|
-
|
|
315
|
+
id: credential.credentialId,
|
|
316
|
+
publicKey: toBuffer(credential.publicKey),
|
|
317
|
+
credentialPublicKey: toBuffer(credential.publicKey),
|
|
231
318
|
counter: credential.counter,
|
|
319
|
+
transports: credential.transports ?? undefined,
|
|
232
320
|
credentialBackedUp: credential.backedUp,
|
|
233
|
-
credentialDeviceType: credential.deviceType
|
|
234
|
-
|
|
235
|
-
|
|
321
|
+
credentialDeviceType: credential.deviceType
|
|
322
|
+
// simplewebauthn accepts either Uint8Array or Buffer; ensure Buffer
|
|
323
|
+
// see https://github.com/MasterKale/SimpleWebAuthn/blob/master/packages/server/src/authentication/verifyAuthenticationResponse.ts
|
|
236
324
|
};
|
|
237
325
|
const result = await (0, server_1.verifyAuthenticationResponse)({
|
|
238
326
|
response,
|
|
239
327
|
expectedChallenge: record.challenge,
|
|
240
328
|
expectedOrigin: this.config.origins,
|
|
241
329
|
expectedRPID: this.config.rpId,
|
|
242
|
-
|
|
330
|
+
credential: storedAuthData,
|
|
243
331
|
requireUserVerification: true
|
|
244
332
|
});
|
|
245
333
|
if (!result.verified) {
|
|
@@ -4,10 +4,10 @@
|
|
|
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
12
|
import type { AuthAdapter, AuthIdentifier } from './auth-api/types.js';
|
|
13
13
|
import type { OAuthStore } from './oauth/base.js';
|
|
@@ -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;
|
|
@@ -197,6 +203,14 @@ export declare class ApiServer {
|
|
|
197
203
|
private normalizeAuthIdentifier;
|
|
198
204
|
private extractTokenUserId;
|
|
199
205
|
private resolveRealUserId;
|
|
206
|
+
useExpress(path: string, ...handlers: Array<RequestHandler | ErrorRequestHandler>): this;
|
|
207
|
+
useExpress(...handlers: Array<RequestHandler | ErrorRequestHandler>): this;
|
|
208
|
+
private createApiRequest;
|
|
209
|
+
expressAuth(auth: {
|
|
210
|
+
type: ApiAuthType;
|
|
211
|
+
req: ApiAuthClass;
|
|
212
|
+
}): RequestHandler;
|
|
213
|
+
expressErrorHandler(): ErrorRequestHandler;
|
|
200
214
|
private handle_request;
|
|
201
215
|
api<T extends ApiModule<any>>(module: T): this;
|
|
202
216
|
dumpRequest(apiReq: ApiRequest): void;
|
|
@@ -725,7 +725,7 @@ export class ApiServer {
|
|
|
725
725
|
token = authHeader.slice(7).trim();
|
|
726
726
|
}
|
|
727
727
|
if (!token) {
|
|
728
|
-
const access = apiReq.req.cookies?.
|
|
728
|
+
const access = apiReq.req.cookies?.[this.config.accessCookie];
|
|
729
729
|
if (access) {
|
|
730
730
|
token = access;
|
|
731
731
|
}
|
|
@@ -831,32 +831,98 @@ export class ApiServer {
|
|
|
831
831
|
}
|
|
832
832
|
return rawReal;
|
|
833
833
|
}
|
|
834
|
-
|
|
834
|
+
useExpress(pathOrHandler, ...handlers) {
|
|
835
|
+
if (typeof pathOrHandler === 'string') {
|
|
836
|
+
this.app.use(pathOrHandler, ...handlers);
|
|
837
|
+
}
|
|
838
|
+
else {
|
|
839
|
+
this.app.use(pathOrHandler, ...handlers);
|
|
840
|
+
}
|
|
841
|
+
this.ensureApiNotFoundOrdering();
|
|
842
|
+
return this;
|
|
843
|
+
}
|
|
844
|
+
createApiRequest(req, res) {
|
|
845
|
+
const apiReq = {
|
|
846
|
+
server: this,
|
|
847
|
+
req,
|
|
848
|
+
res,
|
|
849
|
+
token: '',
|
|
850
|
+
tokenData: null,
|
|
851
|
+
realUid: null,
|
|
852
|
+
getClientInfo: () => ensureClientInfo(apiReq),
|
|
853
|
+
getClientIp: () => ensureClientInfo(apiReq).ip,
|
|
854
|
+
getClientIpChain: () => ensureClientInfo(apiReq).ipchain,
|
|
855
|
+
getRealUid: () => apiReq.realUid ?? null,
|
|
856
|
+
isImpersonating: () => {
|
|
857
|
+
const realUid = apiReq.realUid;
|
|
858
|
+
const tokenUid = apiReq.tokenData?.uid;
|
|
859
|
+
if (realUid === null || realUid === undefined) {
|
|
860
|
+
return false;
|
|
861
|
+
}
|
|
862
|
+
if (tokenUid === null || tokenUid === undefined) {
|
|
863
|
+
return false;
|
|
864
|
+
}
|
|
865
|
+
return realUid !== tokenUid;
|
|
866
|
+
}
|
|
867
|
+
};
|
|
868
|
+
return apiReq;
|
|
869
|
+
}
|
|
870
|
+
expressAuth(auth) {
|
|
835
871
|
return async (req, res, next) => {
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
getClientIpChain: () => ensureClientInfo(apiReq).ipchain,
|
|
847
|
-
getRealUid: () => apiReq.realUid ?? null,
|
|
848
|
-
isImpersonating: () => {
|
|
849
|
-
const realUid = apiReq.realUid;
|
|
850
|
-
const tokenUid = apiReq.tokenData?.uid;
|
|
851
|
-
if (realUid === null || realUid === undefined) {
|
|
852
|
-
return false;
|
|
853
|
-
}
|
|
854
|
-
if (tokenUid === null || tokenUid === undefined) {
|
|
855
|
-
return false;
|
|
856
|
-
}
|
|
857
|
-
return realUid !== tokenUid;
|
|
872
|
+
const apiReq = this.createApiRequest(req, res);
|
|
873
|
+
req.apiReq = apiReq;
|
|
874
|
+
res.locals.apiReq = apiReq;
|
|
875
|
+
this.currReq = apiReq;
|
|
876
|
+
try {
|
|
877
|
+
if (this.config.hydrateGetBody) {
|
|
878
|
+
hydrateGetBody(req);
|
|
879
|
+
}
|
|
880
|
+
if (this.config.debug) {
|
|
881
|
+
this.dumpRequest(apiReq);
|
|
858
882
|
}
|
|
883
|
+
apiReq.tokenData = await this.authenticate(apiReq, auth.type);
|
|
884
|
+
await this.authorize(apiReq, auth.req);
|
|
885
|
+
next();
|
|
886
|
+
}
|
|
887
|
+
catch (error) {
|
|
888
|
+
next(error);
|
|
889
|
+
}
|
|
890
|
+
};
|
|
891
|
+
}
|
|
892
|
+
expressErrorHandler() {
|
|
893
|
+
return (error, _req, res, next) => {
|
|
894
|
+
void _req;
|
|
895
|
+
if (res.headersSent) {
|
|
896
|
+
next(error);
|
|
897
|
+
return;
|
|
898
|
+
}
|
|
899
|
+
if (error instanceof ApiError || isApiErrorLike(error)) {
|
|
900
|
+
const apiError = error;
|
|
901
|
+
const normalizedErrors = apiError.errors && typeof apiError.errors === 'object' && !Array.isArray(apiError.errors)
|
|
902
|
+
? apiError.errors
|
|
903
|
+
: {};
|
|
904
|
+
const errorPayload = {
|
|
905
|
+
code: apiError.code,
|
|
906
|
+
message: apiError.message,
|
|
907
|
+
data: apiError.data ?? null,
|
|
908
|
+
errors: normalizedErrors
|
|
909
|
+
};
|
|
910
|
+
res.status(apiError.code).json(errorPayload);
|
|
911
|
+
return;
|
|
912
|
+
}
|
|
913
|
+
const errorPayload = {
|
|
914
|
+
code: 500,
|
|
915
|
+
message: this.guessExceptionText(error),
|
|
916
|
+
data: null,
|
|
917
|
+
errors: {}
|
|
859
918
|
};
|
|
919
|
+
res.status(500).json(errorPayload);
|
|
920
|
+
};
|
|
921
|
+
}
|
|
922
|
+
handle_request(handler, auth) {
|
|
923
|
+
return async (req, res, next) => {
|
|
924
|
+
void next;
|
|
925
|
+
const apiReq = this.createApiRequest(req, res);
|
|
860
926
|
this.currReq = apiReq;
|
|
861
927
|
try {
|
|
862
928
|
if (this.config.hydrateGetBody) {
|
package/dist/esm/index.d.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
export { default as ApiServer } from './api-server-base.js';
|
|
2
2
|
export { ApiError } from './api-server-base.js';
|
|
3
3
|
export { ApiModule } from './api-module.js';
|
|
4
|
-
export type { ApiErrorParams, ApiHandler, ApiKey, ApiServerConf, ApiRequest, ApiRoute, ApiAuthType, ApiAuthClass, ApiTokenData, ExtendedReq } from './api-server-base.js';
|
|
4
|
+
export type { ApiErrorParams, ApiHandler, ApiKey, ApiServerConf, ApiRequest, ApiRoute, ApiAuthType, ApiAuthClass, ApiTokenData, ExtendedReq, ExpressApiRequest, ExpressApiLocals } from './api-server-base.js';
|
|
5
5
|
export type { AuthIdentifier } from './auth-api/types.js';
|
|
6
6
|
export type { Token, TokenPair, TokenStatus } from './token/types.js';
|
|
7
7
|
export type { JwtSignResult, JwtVerifyResult, JwtDecodeResult } from './token/base.js';
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { generateAuthenticationOptions, generateRegistrationOptions, verifyAuthenticationResponse, verifyRegistrationResponse } from '@simplewebauthn/server';
|
|
2
|
-
import { isoBase64URL } from '@simplewebauthn/server/helpers';
|
|
2
|
+
import { isoBase64URL, isoCBOR, decodeAttestationObject, parseAuthenticatorData } from '@simplewebauthn/server/helpers';
|
|
3
3
|
const ALLOWED_TRANSPORTS = [
|
|
4
4
|
'ble',
|
|
5
5
|
'cable',
|
|
@@ -51,6 +51,32 @@ function toBufferOrNull(value) {
|
|
|
51
51
|
}
|
|
52
52
|
return null;
|
|
53
53
|
}
|
|
54
|
+
async function spkiToCosePublicKey(spki) {
|
|
55
|
+
try {
|
|
56
|
+
const subtle = globalThis.crypto?.subtle ?? (await import('crypto')).webcrypto?.subtle;
|
|
57
|
+
if (!subtle) {
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
const key = await subtle.importKey('spki', spki, { name: 'ECDSA', namedCurve: 'P-256' }, true, ['verify']);
|
|
61
|
+
const raw = Buffer.from(await subtle.exportKey('raw', key));
|
|
62
|
+
if (raw.length !== 65 || raw[0] !== 0x04) {
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
const x = raw.slice(1, 33);
|
|
66
|
+
const y = raw.slice(33, 65);
|
|
67
|
+
const coseMap = new Map([
|
|
68
|
+
[1, 2], // kty: EC2
|
|
69
|
+
[3, -7], // alg: ES256
|
|
70
|
+
[-1, 1], // crv: P-256
|
|
71
|
+
[-2, x],
|
|
72
|
+
[-3, y]
|
|
73
|
+
]);
|
|
74
|
+
return Buffer.from(isoCBOR.encode(coseMap));
|
|
75
|
+
}
|
|
76
|
+
catch {
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
54
80
|
export class PasskeyService {
|
|
55
81
|
constructor(config, adapter, logger = console) {
|
|
56
82
|
this.config = config;
|
|
@@ -192,18 +218,44 @@ export class PasskeyService {
|
|
|
192
218
|
const credentialIdFallback = toBufferOrNull(params.response?.id);
|
|
193
219
|
const credentialId = credentialIdPrimary && credentialIdPrimary.length > 0 ? credentialIdPrimary : credentialIdFallback;
|
|
194
220
|
const publicKeyPrimary = toBufferOrNull(registrationInfo.credentialPublicKey);
|
|
195
|
-
|
|
221
|
+
let publicKeyFallback = toBufferOrNull(attestationResponse?.publicKey);
|
|
222
|
+
if ((!publicKeyPrimary || publicKeyPrimary.length === 0) && attestationResponse?.attestationObject) {
|
|
223
|
+
try {
|
|
224
|
+
const attObj = decodeAttestationObject(isoBase64URL.toBuffer(attestationResponse.attestationObject));
|
|
225
|
+
const parsedAuth = parseAuthenticatorData(attObj.get('authData'));
|
|
226
|
+
publicKeyFallback = toBufferOrNull(parsedAuth.credentialPublicKey) ?? publicKeyFallback;
|
|
227
|
+
}
|
|
228
|
+
catch (error) {
|
|
229
|
+
this.logger.warn?.('Passkey registration: failed to parse attestationObject', error);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
196
232
|
const publicKey = publicKeyPrimary && publicKeyPrimary.length > 0 ? publicKeyPrimary : publicKeyFallback;
|
|
233
|
+
if (this.logger?.warn) {
|
|
234
|
+
const pkPrimaryHex = publicKeyPrimary ? publicKeyPrimary.slice(0, 4).toString('hex') : 'null';
|
|
235
|
+
const pkFallbackHex = publicKeyFallback ? publicKeyFallback.slice(0, 4).toString('hex') : 'null';
|
|
236
|
+
this.logger.warn(`Passkey registration: pkPrimary len=${publicKeyPrimary?.length ?? 0} head=${pkPrimaryHex}, pkFallback len=${publicKeyFallback?.length ?? 0} head=${pkFallbackHex}`);
|
|
237
|
+
}
|
|
197
238
|
if (!credentialId || credentialId.length === 0) {
|
|
198
239
|
return { verified: false, message: 'Missing credential id in registration response' };
|
|
199
240
|
}
|
|
200
241
|
if (!publicKey || publicKey.length === 0) {
|
|
201
242
|
return { verified: false, message: 'Missing public key in registration response' };
|
|
202
243
|
}
|
|
244
|
+
let storedPublicKey = Buffer.isBuffer(publicKey) ? publicKey : toBuffer(publicKey);
|
|
245
|
+
// If fallback is an SPKI key (DER, starts with 0x30) convert to COSE so verification succeeds.
|
|
246
|
+
if (storedPublicKey[0] === 0x30) {
|
|
247
|
+
const converted = await spkiToCosePublicKey(storedPublicKey);
|
|
248
|
+
if (converted) {
|
|
249
|
+
storedPublicKey = converted;
|
|
250
|
+
}
|
|
251
|
+
else {
|
|
252
|
+
this.logger.warn?.('Passkey registration: failed to convert SPKI public key to COSE');
|
|
253
|
+
}
|
|
254
|
+
}
|
|
203
255
|
await this.adapter.saveCredential({
|
|
204
256
|
userId: user.id,
|
|
205
257
|
credentialId,
|
|
206
|
-
publicKey,
|
|
258
|
+
publicKey: storedPublicKey,
|
|
207
259
|
counter: registrationInfo.counter ?? 0,
|
|
208
260
|
transports: sanitizeTransports(params.response.transports),
|
|
209
261
|
backedUp: registrationInfo.credentialDeviceType === 'multiDevice',
|
|
@@ -224,19 +276,22 @@ export class PasskeyService {
|
|
|
224
276
|
}
|
|
225
277
|
const user = await this.requireUser({ userId: credential.userId, login: record.login });
|
|
226
278
|
const storedAuthData = {
|
|
227
|
-
|
|
279
|
+
id: credential.credentialId,
|
|
280
|
+
publicKey: toBuffer(credential.publicKey),
|
|
281
|
+
credentialPublicKey: toBuffer(credential.publicKey),
|
|
228
282
|
counter: credential.counter,
|
|
283
|
+
transports: credential.transports ?? undefined,
|
|
229
284
|
credentialBackedUp: credential.backedUp,
|
|
230
|
-
credentialDeviceType: credential.deviceType
|
|
231
|
-
|
|
232
|
-
|
|
285
|
+
credentialDeviceType: credential.deviceType
|
|
286
|
+
// simplewebauthn accepts either Uint8Array or Buffer; ensure Buffer
|
|
287
|
+
// see https://github.com/MasterKale/SimpleWebAuthn/blob/master/packages/server/src/authentication/verifyAuthenticationResponse.ts
|
|
233
288
|
};
|
|
234
289
|
const result = await verifyAuthenticationResponse({
|
|
235
290
|
response,
|
|
236
291
|
expectedChallenge: record.challenge,
|
|
237
292
|
expectedOrigin: this.config.origins,
|
|
238
293
|
expectedRPID: this.config.rpId,
|
|
239
|
-
|
|
294
|
+
credential: storedAuthData,
|
|
240
295
|
requireUserVerification: true
|
|
241
296
|
});
|
|
242
297
|
if (!result.verified) {
|