@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 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` cookie 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.
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?.dat;
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
- handle_request(handler, auth) {
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
- void next;
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;
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;
@@ -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
- const publicKeyFallback = toBufferOrNull(attestationResponse?.publicKey);
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
- credentialID: credential.credentialId,
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
- credentialPublicKey: credential.publicKey,
235
- transports: credential.transports ?? undefined
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
- authenticator: storedAuthData,
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?.dat;
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
- handle_request(handler, auth) {
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
- void next;
837
- const apiReq = {
838
- server: this,
839
- req,
840
- res,
841
- token: '',
842
- tokenData: null,
843
- realUid: null,
844
- getClientInfo: () => ensureClientInfo(apiReq),
845
- getClientIp: () => ensureClientInfo(apiReq).ip,
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) {
@@ -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
- const publicKeyFallback = toBufferOrNull(attestationResponse?.publicKey);
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
- credentialID: credential.credentialId,
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
- credentialPublicKey: credential.publicKey,
232
- transports: credential.transports ?? undefined
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
- authenticator: storedAuthData,
294
+ credential: storedAuthData,
240
295
  requireUserVerification: true
241
296
  });
242
297
  if (!result.verified) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@technomoron/api-server-base",
3
- "version": "2.0.0-beta.6",
3
+ "version": "2.0.0-beta.9",
4
4
  "description": "Api Server Skeleton / Base Class",
5
5
  "type": "module",
6
6
  "main": "./dist/cjs/index.cjs",