@technomoron/api-server-base 1.1.13 → 2.0.0-beta.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (116) hide show
  1. package/README.txt +25 -2
  2. package/dist/cjs/api-server-base.cjs +448 -111
  3. package/dist/cjs/api-server-base.d.ts +91 -34
  4. package/dist/cjs/auth-api/auth-module.d.ts +105 -0
  5. package/dist/cjs/auth-api/auth-module.js +1180 -0
  6. package/dist/cjs/auth-api/compat-auth-storage.d.ts +57 -0
  7. package/dist/cjs/auth-api/compat-auth-storage.js +128 -0
  8. package/dist/cjs/auth-api/mem-auth-store.d.ts +68 -0
  9. package/dist/cjs/auth-api/mem-auth-store.js +141 -0
  10. package/dist/cjs/{auth-module.d.ts → auth-api/module.d.ts} +7 -7
  11. package/dist/cjs/{auth-module.cjs → auth-api/module.js} +1 -1
  12. package/dist/cjs/auth-api/sql-auth-store.d.ts +77 -0
  13. package/dist/cjs/auth-api/sql-auth-store.js +172 -0
  14. package/dist/cjs/auth-api/storage.d.ts +38 -0
  15. package/dist/cjs/{auth-storage.cjs → auth-api/storage.js} +17 -7
  16. package/dist/cjs/auth-api/types.d.ts +34 -0
  17. package/dist/cjs/auth-api/types.js +2 -0
  18. package/dist/cjs/index.cjs +41 -7
  19. package/dist/cjs/index.d.ts +29 -5
  20. package/dist/cjs/oauth/base.d.ts +10 -0
  21. package/dist/cjs/oauth/base.js +6 -0
  22. package/dist/cjs/oauth/memory.d.ts +16 -0
  23. package/dist/cjs/oauth/memory.js +99 -0
  24. package/dist/cjs/oauth/models.d.ts +45 -0
  25. package/dist/cjs/oauth/models.js +58 -0
  26. package/dist/cjs/oauth/sequelize.d.ts +68 -0
  27. package/dist/cjs/oauth/sequelize.js +210 -0
  28. package/dist/cjs/oauth/types.d.ts +50 -0
  29. package/dist/cjs/oauth/types.js +3 -0
  30. package/dist/cjs/passkey/base.d.ts +16 -0
  31. package/dist/cjs/passkey/base.js +6 -0
  32. package/dist/cjs/passkey/memory.d.ts +27 -0
  33. package/dist/cjs/passkey/memory.js +86 -0
  34. package/dist/cjs/passkey/models.d.ts +25 -0
  35. package/dist/cjs/passkey/models.js +115 -0
  36. package/dist/cjs/passkey/sequelize.d.ts +55 -0
  37. package/dist/cjs/passkey/sequelize.js +220 -0
  38. package/dist/cjs/passkey/service.d.ts +20 -0
  39. package/dist/cjs/passkey/service.js +356 -0
  40. package/dist/cjs/passkey/types.d.ts +78 -0
  41. package/dist/cjs/passkey/types.js +2 -0
  42. package/dist/cjs/token/base.d.ts +38 -0
  43. package/dist/cjs/token/base.js +114 -0
  44. package/dist/cjs/token/memory.d.ts +19 -0
  45. package/dist/cjs/token/memory.js +149 -0
  46. package/dist/cjs/token/sequelize.d.ts +58 -0
  47. package/dist/cjs/token/sequelize.js +404 -0
  48. package/dist/cjs/token/types.d.ts +27 -0
  49. package/dist/cjs/token/types.js +2 -0
  50. package/dist/cjs/user/base.d.ts +26 -0
  51. package/dist/cjs/user/base.js +45 -0
  52. package/dist/cjs/user/memory.d.ts +35 -0
  53. package/dist/cjs/user/memory.js +173 -0
  54. package/dist/cjs/user/sequelize.d.ts +41 -0
  55. package/dist/cjs/user/sequelize.js +182 -0
  56. package/dist/cjs/user/types.d.ts +11 -0
  57. package/dist/cjs/user/types.js +2 -0
  58. package/dist/esm/api-server-base.d.ts +91 -34
  59. package/dist/esm/api-server-base.js +447 -110
  60. package/dist/esm/auth-api/auth-module.d.ts +105 -0
  61. package/dist/esm/auth-api/auth-module.js +1178 -0
  62. package/dist/esm/auth-api/compat-auth-storage.d.ts +57 -0
  63. package/dist/esm/auth-api/compat-auth-storage.js +124 -0
  64. package/dist/esm/auth-api/mem-auth-store.d.ts +68 -0
  65. package/dist/esm/auth-api/mem-auth-store.js +137 -0
  66. package/dist/esm/{auth-module.d.ts → auth-api/module.d.ts} +7 -7
  67. package/dist/esm/{auth-module.js → auth-api/module.js} +1 -1
  68. package/dist/esm/auth-api/sql-auth-store.d.ts +77 -0
  69. package/dist/esm/auth-api/sql-auth-store.js +168 -0
  70. package/dist/esm/auth-api/storage.d.ts +38 -0
  71. package/dist/esm/{auth-storage.js → auth-api/storage.js} +15 -5
  72. package/dist/esm/auth-api/types.d.ts +34 -0
  73. package/dist/esm/auth-api/types.js +1 -0
  74. package/dist/esm/index.d.ts +29 -5
  75. package/dist/esm/index.js +19 -2
  76. package/dist/esm/oauth/base.d.ts +10 -0
  77. package/dist/esm/oauth/base.js +2 -0
  78. package/dist/esm/oauth/memory.d.ts +16 -0
  79. package/dist/esm/oauth/memory.js +92 -0
  80. package/dist/esm/oauth/models.d.ts +45 -0
  81. package/dist/esm/oauth/models.js +51 -0
  82. package/dist/esm/oauth/sequelize.d.ts +68 -0
  83. package/dist/esm/oauth/sequelize.js +199 -0
  84. package/dist/esm/oauth/types.d.ts +50 -0
  85. package/dist/esm/oauth/types.js +2 -0
  86. package/dist/esm/passkey/base.d.ts +16 -0
  87. package/dist/esm/passkey/base.js +2 -0
  88. package/dist/esm/passkey/memory.d.ts +27 -0
  89. package/dist/esm/passkey/memory.js +82 -0
  90. package/dist/esm/passkey/models.d.ts +25 -0
  91. package/dist/esm/passkey/models.js +108 -0
  92. package/dist/esm/passkey/sequelize.d.ts +55 -0
  93. package/dist/esm/passkey/sequelize.js +216 -0
  94. package/dist/esm/passkey/service.d.ts +20 -0
  95. package/dist/esm/passkey/service.js +319 -0
  96. package/dist/esm/passkey/types.d.ts +78 -0
  97. package/dist/esm/passkey/types.js +1 -0
  98. package/dist/esm/token/base.d.ts +38 -0
  99. package/dist/esm/token/base.js +107 -0
  100. package/dist/esm/token/memory.d.ts +19 -0
  101. package/dist/esm/token/memory.js +145 -0
  102. package/dist/esm/token/sequelize.d.ts +58 -0
  103. package/dist/esm/token/sequelize.js +400 -0
  104. package/dist/esm/token/types.d.ts +27 -0
  105. package/dist/esm/token/types.js +1 -0
  106. package/dist/esm/user/base.d.ts +26 -0
  107. package/dist/esm/user/base.js +38 -0
  108. package/dist/esm/user/memory.d.ts +35 -0
  109. package/dist/esm/user/memory.js +169 -0
  110. package/dist/esm/user/sequelize.d.ts +41 -0
  111. package/dist/esm/user/sequelize.js +176 -0
  112. package/dist/esm/user/types.d.ts +11 -0
  113. package/dist/esm/user/types.js +1 -0
  114. package/package.json +13 -3
  115. package/dist/cjs/auth-storage.d.ts +0 -133
  116. package/dist/esm/auth-storage.d.ts +0 -133
@@ -0,0 +1,216 @@
1
+ import { DataTypes, Model, Op } from 'sequelize';
2
+ import { PasskeyStore } from './base.js';
3
+ const DIALECTS_SUPPORTING_UNSIGNED = new Set(['mysql', 'mariadb']);
4
+ function integerIdType(sequelize) {
5
+ return DIALECTS_SUPPORTING_UNSIGNED.has(sequelize.getDialect()) ? DataTypes.INTEGER.UNSIGNED : DataTypes.INTEGER;
6
+ }
7
+ function encodeCredentialId(value) {
8
+ return Buffer.isBuffer(value) ? value.toString('base64') : value;
9
+ }
10
+ function normalizeUserId(identifier) {
11
+ if (typeof identifier === 'number' && Number.isFinite(identifier)) {
12
+ return identifier;
13
+ }
14
+ if (typeof identifier === 'string' && /^\d+$/.test(identifier)) {
15
+ return Number(identifier);
16
+ }
17
+ throw new Error(`Unable to normalise user identifier: ${identifier}`);
18
+ }
19
+ class PasskeyCredentialModel extends Model {
20
+ }
21
+ class PasskeyChallengeModel extends Model {
22
+ }
23
+ function initPasskeyCredentialModel(sequelize) {
24
+ const idType = integerIdType(sequelize);
25
+ return PasskeyCredentialModel.init({
26
+ credentialId: {
27
+ field: 'credential_id',
28
+ type: DataTypes.STRING(768),
29
+ primaryKey: true,
30
+ allowNull: false,
31
+ get() {
32
+ const raw = this.getDataValue('credentialId');
33
+ if (!raw) {
34
+ return raw;
35
+ }
36
+ if (Buffer.isBuffer(raw)) {
37
+ return raw;
38
+ }
39
+ return Buffer.from(raw, 'base64');
40
+ },
41
+ set(value) {
42
+ const encoded = typeof value === 'string' ? value : value.toString('base64');
43
+ this.setDataValue('credentialId', encoded);
44
+ }
45
+ },
46
+ userId: {
47
+ field: 'user_id',
48
+ type: idType,
49
+ allowNull: false
50
+ },
51
+ publicKey: {
52
+ field: 'public_key',
53
+ type: DataTypes.BLOB,
54
+ allowNull: false
55
+ },
56
+ counter: {
57
+ type: DataTypes.INTEGER,
58
+ allowNull: false,
59
+ defaultValue: 0
60
+ },
61
+ transports: {
62
+ type: DataTypes.JSON,
63
+ allowNull: true
64
+ },
65
+ backedUp: {
66
+ field: 'backed_up',
67
+ type: DataTypes.BOOLEAN,
68
+ allowNull: false,
69
+ defaultValue: false
70
+ },
71
+ deviceType: {
72
+ field: 'device_type',
73
+ type: DataTypes.STRING(32),
74
+ allowNull: false,
75
+ defaultValue: 'multiDevice'
76
+ }
77
+ }, {
78
+ sequelize,
79
+ tableName: 'passkey_credentials',
80
+ timestamps: true,
81
+ underscored: true
82
+ });
83
+ }
84
+ function initPasskeyChallengeModel(sequelize) {
85
+ const idType = integerIdType(sequelize);
86
+ return PasskeyChallengeModel.init({
87
+ challenge: {
88
+ type: DataTypes.STRING(255),
89
+ primaryKey: true,
90
+ allowNull: false
91
+ },
92
+ action: {
93
+ type: DataTypes.STRING(16),
94
+ allowNull: false
95
+ },
96
+ userId: {
97
+ field: 'user_id',
98
+ type: idType,
99
+ allowNull: true
100
+ },
101
+ login: {
102
+ type: DataTypes.STRING(128),
103
+ allowNull: true
104
+ },
105
+ metadata: {
106
+ type: DataTypes.JSON,
107
+ allowNull: true
108
+ },
109
+ expiresAt: {
110
+ field: 'expires_at',
111
+ type: DataTypes.DATE,
112
+ allowNull: false
113
+ }
114
+ }, {
115
+ sequelize,
116
+ tableName: 'passkey_challenges',
117
+ timestamps: true,
118
+ underscored: true,
119
+ indexes: [{ fields: ['expires_at'] }, { fields: ['user_id'] }]
120
+ });
121
+ }
122
+ export class SequelizePasskeyStore extends PasskeyStore {
123
+ constructor(options) {
124
+ super();
125
+ if (!options?.sequelize) {
126
+ throw new Error('SequelizePasskeyStore requires an initialised Sequelize instance');
127
+ }
128
+ this.resolveUserFn = options.resolveUser;
129
+ this.credentials =
130
+ options.credentialModel ??
131
+ (options.credentialModelFactory ?? initPasskeyCredentialModel)(options.sequelize);
132
+ this.challenges =
133
+ options.challengeModel ?? (options.challengeModelFactory ?? initPasskeyChallengeModel)(options.sequelize);
134
+ }
135
+ async resolveUser(params) {
136
+ return this.resolveUserFn(params);
137
+ }
138
+ async listUserCredentials(userId) {
139
+ const models = await this.credentials.findAll({ where: { userId: normalizeUserId(userId) } });
140
+ return models.map((model) => ({
141
+ userId: model.userId,
142
+ credentialId: model.credentialId,
143
+ publicKey: model.publicKey,
144
+ counter: model.counter,
145
+ transports: (model.transports ?? undefined),
146
+ backedUp: model.backedUp,
147
+ deviceType: model.deviceType,
148
+ createdAt: model.createdAt ?? undefined,
149
+ updatedAt: model.updatedAt ?? undefined
150
+ }));
151
+ }
152
+ async deleteCredential(credentialId) {
153
+ const encoded = Buffer.isBuffer(credentialId) ? credentialId.toString('base64') : credentialId;
154
+ const deleted = await this.credentials.destroy({ where: { credentialId: encoded } });
155
+ return deleted > 0;
156
+ }
157
+ async findCredentialById(credentialId) {
158
+ const model = await this.credentials.findByPk(encodeCredentialId(credentialId));
159
+ if (!model) {
160
+ return null;
161
+ }
162
+ return {
163
+ userId: model.userId,
164
+ credentialId: model.credentialId,
165
+ publicKey: model.publicKey,
166
+ counter: model.counter,
167
+ transports: (model.transports ?? undefined),
168
+ backedUp: model.backedUp,
169
+ deviceType: model.deviceType,
170
+ createdAt: model.createdAt ?? undefined,
171
+ updatedAt: model.updatedAt ?? undefined
172
+ };
173
+ }
174
+ async saveCredential(record) {
175
+ await this.credentials.upsert({
176
+ credentialId: record.credentialId,
177
+ userId: normalizeUserId(record.userId),
178
+ publicKey: record.publicKey,
179
+ counter: record.counter,
180
+ transports: record.transports ?? null,
181
+ backedUp: record.backedUp,
182
+ deviceType: record.deviceType
183
+ });
184
+ }
185
+ async updateCredentialCounter(credentialId, counter) {
186
+ await this.credentials.update({ counter }, { where: { credentialId: encodeCredentialId(credentialId) } });
187
+ }
188
+ async saveChallenge(record) {
189
+ await this.challenges.upsert({
190
+ challenge: record.challenge,
191
+ action: record.action,
192
+ userId: record.userId !== undefined ? normalizeUserId(record.userId) : null,
193
+ login: record.login ?? null,
194
+ metadata: (record.metadata ?? {}),
195
+ expiresAt: record.expiresAt
196
+ });
197
+ }
198
+ async consumeChallenge(challenge) {
199
+ const model = await this.challenges.findByPk(challenge);
200
+ if (!model) {
201
+ return null;
202
+ }
203
+ await model.destroy();
204
+ return {
205
+ challenge: model.challenge,
206
+ action: model.action,
207
+ userId: model.userId ?? undefined,
208
+ login: model.login ?? undefined,
209
+ expiresAt: model.expiresAt,
210
+ metadata: model.metadata ?? {}
211
+ };
212
+ }
213
+ async cleanupChallenges(now) {
214
+ await this.challenges.destroy({ where: { expiresAt: { [Op.lte]: now } } });
215
+ }
216
+ }
@@ -0,0 +1,20 @@
1
+ import type { PasskeyChallenge, PasskeyChallengeParams, PasskeyStorageAdapter, PasskeyVerificationParams, PasskeyVerificationResult, PasskeyServiceConfig, StoredPasskeyCredential } from './types.js';
2
+ import type { AuthIdentifier } from '../auth-api/types.js';
3
+ export type { CredentialDeviceType, PasskeyChallenge, PasskeyChallengeParams, PasskeyChallengeRecord, PasskeyChallengeMetadata, PasskeyUserDescriptor, PasskeyVerificationParams, PasskeyVerificationResult, PasskeyServiceConfig, PasskeyStorageAdapter, StoredPasskeyCredential } from './types.js';
4
+ type Logger = Pick<typeof console, 'error' | 'warn'>;
5
+ export declare class PasskeyService {
6
+ private readonly config;
7
+ private readonly adapter;
8
+ private readonly logger;
9
+ constructor(config: PasskeyServiceConfig, adapter: PasskeyStorageAdapter, logger?: Logger);
10
+ listUserCredentials(userId: AuthIdentifier): Promise<StoredPasskeyCredential[]>;
11
+ deleteCredential(credentialId: Buffer | string): Promise<boolean>;
12
+ createChallenge(params: PasskeyChallengeParams): Promise<PasskeyChallenge>;
13
+ verifyResponse(params: PasskeyVerificationParams): Promise<PasskeyVerificationResult>;
14
+ private createRegistrationChallenge;
15
+ private createAuthenticationChallenge;
16
+ private verifyRegistration;
17
+ private verifyAuthentication;
18
+ private requireUser;
19
+ private createExpiry;
20
+ }
@@ -0,0 +1,319 @@
1
+ import { generateAuthenticationOptions, generateRegistrationOptions, verifyAuthenticationResponse, verifyRegistrationResponse } from '@simplewebauthn/server';
2
+ import { isoBase64URL, isoCBOR, decodeAttestationObject, parseAuthenticatorData } from '@simplewebauthn/server/helpers';
3
+ const ALLOWED_TRANSPORTS = [
4
+ 'ble',
5
+ 'cable',
6
+ 'hybrid',
7
+ 'internal',
8
+ 'nfc',
9
+ 'smart-card',
10
+ 'usb'
11
+ ];
12
+ function sanitizeTransports(input) {
13
+ if (!Array.isArray(input)) {
14
+ return undefined;
15
+ }
16
+ const filtered = input
17
+ .map((value) => String(value))
18
+ .filter((value) => ALLOWED_TRANSPORTS.includes(value));
19
+ return filtered.length > 0 ? filtered : undefined;
20
+ }
21
+ function toBase64Url(buffer) {
22
+ return isoBase64URL.fromBuffer(new Uint8Array(buffer));
23
+ }
24
+ function fromBase64Url(value) {
25
+ return Buffer.from(isoBase64URL.toBuffer(value));
26
+ }
27
+ function toBuffer(value) {
28
+ if (Buffer.isBuffer(value)) {
29
+ return value;
30
+ }
31
+ const view = value instanceof Uint8Array ? value : new Uint8Array(value);
32
+ return Buffer.from(view);
33
+ }
34
+ function toBufferOrNull(value) {
35
+ if (!value) {
36
+ return null;
37
+ }
38
+ if (Buffer.isBuffer(value)) {
39
+ return value;
40
+ }
41
+ if (value instanceof ArrayBuffer || value instanceof Uint8Array) {
42
+ return toBuffer(value);
43
+ }
44
+ if (typeof value === 'string') {
45
+ try {
46
+ return fromBase64Url(value);
47
+ }
48
+ catch {
49
+ return null;
50
+ }
51
+ }
52
+ return null;
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
+ }
80
+ export class PasskeyService {
81
+ constructor(config, adapter, logger = console) {
82
+ this.config = config;
83
+ this.adapter = adapter;
84
+ this.logger = logger;
85
+ }
86
+ async listUserCredentials(userId) {
87
+ return this.adapter.listUserCredentials(userId);
88
+ }
89
+ async deleteCredential(credentialId) {
90
+ return this.adapter.deleteCredential(credentialId);
91
+ }
92
+ async createChallenge(params) {
93
+ await this.adapter.cleanupChallenges?.(new Date());
94
+ const metadata = {
95
+ domain: typeof params.domain === 'string' ? params.domain : undefined,
96
+ fingerprint: typeof params.fingerprint === 'string' ? params.fingerprint : undefined,
97
+ label: typeof params.label === 'string' ? params.label : undefined,
98
+ userAgent: typeof params.userAgent === 'string' ? params.userAgent : undefined
99
+ };
100
+ if (params.action === 'register') {
101
+ return this.createRegistrationChallenge(params, metadata);
102
+ }
103
+ if (params.action === 'authenticate') {
104
+ return this.createAuthenticationChallenge(params, metadata);
105
+ }
106
+ throw new Error(`Unsupported passkey action: ${String(params.action)}`);
107
+ }
108
+ async verifyResponse(params) {
109
+ await this.adapter.cleanupChallenges?.(new Date());
110
+ const record = await this.adapter.consumeChallenge(params.expectedChallenge);
111
+ if (!record) {
112
+ return { verified: false };
113
+ }
114
+ if (record.expiresAt.getTime() <= Date.now()) {
115
+ return { verified: false };
116
+ }
117
+ try {
118
+ if (record.action === 'register') {
119
+ return this.verifyRegistration(params, record);
120
+ }
121
+ if (record.action === 'authenticate') {
122
+ return this.verifyAuthentication(params, record);
123
+ }
124
+ }
125
+ catch (error) {
126
+ this.logger.error?.('Passkey verification failed', error);
127
+ }
128
+ return { verified: false };
129
+ }
130
+ async createRegistrationChallenge(params, metadata) {
131
+ const user = await this.requireUser({ userId: params.userId, login: params.login });
132
+ const existing = await this.adapter.listUserCredentials(user.id);
133
+ const excludeCredentials = existing.map((credential) => {
134
+ const transports = credential.transports;
135
+ return transports && transports.length > 0
136
+ ? { id: toBase64Url(credential.credentialId), transports }
137
+ : { id: toBase64Url(credential.credentialId) };
138
+ });
139
+ const options = await generateRegistrationOptions({
140
+ rpName: this.config.rpName,
141
+ rpID: this.config.rpId,
142
+ userID: Buffer.from(String(user.id)),
143
+ userName: user.login,
144
+ userDisplayName: user.displayName,
145
+ excludeCredentials
146
+ });
147
+ const expiresAt = this.createExpiry();
148
+ await this.adapter.saveChallenge({
149
+ challenge: options.challenge,
150
+ action: 'register',
151
+ userId: user.id,
152
+ login: user.login,
153
+ expiresAt,
154
+ metadata
155
+ });
156
+ return {
157
+ challenge: options.challenge,
158
+ expiresAt: expiresAt.toISOString(),
159
+ userId: user.id,
160
+ publicKey: options
161
+ };
162
+ }
163
+ async createAuthenticationChallenge(params, metadata) {
164
+ const user = await this.requireUser({ userId: params.userId, login: params.login });
165
+ const credentials = await this.adapter.listUserCredentials(user.id);
166
+ const allowCredentials = credentials.map((credential) => {
167
+ const transports = sanitizeTransports(credential.transports);
168
+ return transports && transports.length > 0
169
+ ? { type: 'public-key', id: toBase64Url(credential.credentialId), transports }
170
+ : { type: 'public-key', id: toBase64Url(credential.credentialId) };
171
+ });
172
+ const options = await generateAuthenticationOptions({
173
+ allowCredentials,
174
+ userVerification: this.config.userVerification,
175
+ rpID: this.config.rpId
176
+ });
177
+ const expiresAt = this.createExpiry();
178
+ await this.adapter.saveChallenge({
179
+ challenge: options.challenge,
180
+ action: 'authenticate',
181
+ userId: user.id,
182
+ login: user.login,
183
+ expiresAt,
184
+ metadata
185
+ });
186
+ return {
187
+ challenge: options.challenge,
188
+ expiresAt: expiresAt.toISOString(),
189
+ userId: user.id,
190
+ publicKey: options
191
+ };
192
+ }
193
+ async verifyRegistration(params, record) {
194
+ const parsed = typeof params.response === 'object' && params.response ? { ...params.response } : {};
195
+ const response = {
196
+ ...parsed,
197
+ id: String(parsed.id ?? ''),
198
+ rawId: String(parsed.rawId ?? '')
199
+ };
200
+ const user = await this.requireUser({ userId: record.userId, login: record.login });
201
+ const result = await verifyRegistrationResponse({
202
+ response,
203
+ expectedChallenge: record.challenge,
204
+ expectedOrigin: this.config.origins,
205
+ expectedRPID: this.config.rpId,
206
+ requireUserVerification: true
207
+ });
208
+ if (!result.verified || !result.registrationInfo) {
209
+ if (!result.verified) {
210
+ const err = result.error ?? result;
211
+ this.logger.error?.('Passkey registration verification failed', err);
212
+ }
213
+ return { verified: false };
214
+ }
215
+ const registrationInfo = result.registrationInfo;
216
+ const attestationResponse = params.response?.response;
217
+ const credentialIdPrimary = toBufferOrNull(registrationInfo.credentialID);
218
+ const credentialIdFallback = toBufferOrNull(params.response?.id);
219
+ const credentialId = credentialIdPrimary && credentialIdPrimary.length > 0 ? credentialIdPrimary : credentialIdFallback;
220
+ const publicKeyPrimary = toBufferOrNull(registrationInfo.credentialPublicKey);
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
+ }
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
+ }
238
+ if (!credentialId || credentialId.length === 0) {
239
+ return { verified: false, message: 'Missing credential id in registration response' };
240
+ }
241
+ if (!publicKey || publicKey.length === 0) {
242
+ return { verified: false, message: 'Missing public key in registration response' };
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
+ }
255
+ await this.adapter.saveCredential({
256
+ userId: user.id,
257
+ credentialId,
258
+ publicKey: storedPublicKey,
259
+ counter: registrationInfo.counter ?? 0,
260
+ transports: sanitizeTransports(params.response.transports),
261
+ backedUp: registrationInfo.credentialDeviceType === 'multiDevice',
262
+ deviceType: registrationInfo.credentialDeviceType
263
+ });
264
+ return { verified: true, userId: user.id, login: user.login };
265
+ }
266
+ async verifyAuthentication(params, record) {
267
+ const parsed = typeof params.response === 'object' && params.response ? { ...params.response } : {};
268
+ const response = {
269
+ ...parsed,
270
+ id: String(parsed.id ?? ''),
271
+ rawId: String(parsed.rawId ?? '')
272
+ };
273
+ const credential = await this.adapter.findCredentialById(fromBase64Url(response.id));
274
+ if (!credential) {
275
+ return { verified: false };
276
+ }
277
+ const user = await this.requireUser({ userId: credential.userId, login: record.login });
278
+ const storedAuthData = {
279
+ id: credential.credentialId,
280
+ publicKey: toBuffer(credential.publicKey),
281
+ credentialPublicKey: toBuffer(credential.publicKey),
282
+ counter: credential.counter,
283
+ transports: credential.transports ?? undefined,
284
+ credentialBackedUp: credential.backedUp,
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
288
+ };
289
+ const result = await verifyAuthenticationResponse({
290
+ response,
291
+ expectedChallenge: record.challenge,
292
+ expectedOrigin: this.config.origins,
293
+ expectedRPID: this.config.rpId,
294
+ credential: storedAuthData,
295
+ requireUserVerification: true
296
+ });
297
+ if (!result.verified) {
298
+ const err = result.error ?? result;
299
+ this.logger.error?.('Passkey authentication verification failed', err);
300
+ return { verified: false };
301
+ }
302
+ await this.adapter.updateCredentialCounter(credential.credentialId, result.authenticationInfo.newCounter);
303
+ return {
304
+ verified: true,
305
+ userId: user.id,
306
+ login: user.login
307
+ };
308
+ }
309
+ async requireUser(params) {
310
+ const user = await this.adapter.resolveUser(params);
311
+ if (!user) {
312
+ throw new Error('User not found');
313
+ }
314
+ return user;
315
+ }
316
+ createExpiry() {
317
+ return new Date(Date.now() + this.config.timeoutMs);
318
+ }
319
+ }
@@ -0,0 +1,78 @@
1
+ import type { AuthIdentifier } from '../auth-api/types.js';
2
+ import type { Token, TokenPair } from '../token/types.js';
3
+ import type { AuthenticatorTransportFuture } from '@simplewebauthn/server';
4
+ export type CredentialDeviceType = 'singleDevice' | 'multiDevice';
5
+ export interface PasskeyServiceConfig {
6
+ rpId: string;
7
+ rpName: string;
8
+ origins: string[];
9
+ timeoutMs: number;
10
+ userVerification?: 'preferred' | 'required' | 'discouraged';
11
+ }
12
+ export interface PasskeyChallengeMetadata {
13
+ domain?: string;
14
+ fingerprint?: string;
15
+ label?: string;
16
+ userAgent?: string;
17
+ }
18
+ export interface PasskeyChallengeRecord {
19
+ challenge: string;
20
+ action: 'register' | 'authenticate';
21
+ userId?: AuthIdentifier;
22
+ login?: string;
23
+ expiresAt: Date;
24
+ metadata: PasskeyChallengeMetadata;
25
+ }
26
+ export interface PasskeyUserDescriptor {
27
+ id: AuthIdentifier;
28
+ login: string;
29
+ displayName: string;
30
+ }
31
+ export interface StoredPasskeyCredential {
32
+ userId: AuthIdentifier;
33
+ credentialId: Buffer;
34
+ publicKey: Buffer;
35
+ counter: number;
36
+ transports?: AuthenticatorTransportFuture[];
37
+ backedUp: boolean;
38
+ deviceType: CredentialDeviceType;
39
+ createdAt?: Date;
40
+ updatedAt?: Date;
41
+ }
42
+ export interface PasskeyStorageAdapter {
43
+ resolveUser(params: {
44
+ userId?: AuthIdentifier;
45
+ login?: string;
46
+ }): Promise<PasskeyUserDescriptor | null>;
47
+ listUserCredentials(userId: AuthIdentifier): Promise<StoredPasskeyCredential[]>;
48
+ deleteCredential(credentialId: Buffer | string): Promise<boolean>;
49
+ findCredentialById(credentialId: Buffer): Promise<StoredPasskeyCredential | null>;
50
+ saveCredential(record: StoredPasskeyCredential): Promise<void>;
51
+ updateCredentialCounter(credentialId: Buffer, counter: number): Promise<void>;
52
+ saveChallenge(record: PasskeyChallengeRecord): Promise<void>;
53
+ consumeChallenge(challenge: string): Promise<PasskeyChallengeRecord | null>;
54
+ cleanupChallenges?(now: Date): Promise<void>;
55
+ }
56
+ export interface PasskeyChallengeParams extends Partial<Omit<Token, 'userId'>> {
57
+ action: 'register' | 'authenticate';
58
+ login?: string;
59
+ userAgent?: string;
60
+ userId?: AuthIdentifier;
61
+ }
62
+ export interface PasskeyChallenge extends Record<string, unknown> {
63
+ challenge: string;
64
+ expiresAt?: string | number | Date;
65
+ userId?: AuthIdentifier;
66
+ }
67
+ export interface PasskeyVerificationParams extends Partial<Omit<Token, 'userId'>> {
68
+ expectedChallenge: string;
69
+ login?: string;
70
+ response: Record<string, unknown>;
71
+ userId?: AuthIdentifier;
72
+ }
73
+ export interface PasskeyVerificationResult extends Record<string, unknown> {
74
+ login?: string;
75
+ tokens?: TokenPair;
76
+ userId?: AuthIdentifier;
77
+ verified: boolean;
78
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,38 @@
1
+ import type { Token } from './types.js';
2
+ import type { DecodeOptions, SignOptions, VerifyOptions } from 'jsonwebtoken';
3
+ export interface JwtSignResult {
4
+ success: boolean;
5
+ token?: string;
6
+ error?: string;
7
+ }
8
+ export interface JwtVerifyResult<T> {
9
+ success: boolean;
10
+ data?: T;
11
+ expired?: boolean;
12
+ error?: string;
13
+ }
14
+ export interface JwtDecodeResult<T> {
15
+ success: boolean;
16
+ data?: T;
17
+ error?: string;
18
+ }
19
+ export declare abstract class TokenStore {
20
+ abstract save(record: Token): Promise<void>;
21
+ abstract get(query: Partial<Token>, opts?: {
22
+ includeExpired?: boolean;
23
+ }): Promise<Token | null>;
24
+ abstract delete(query: Partial<Token>): Promise<number>;
25
+ abstract update(update: Partial<Token> & {
26
+ refreshToken: string;
27
+ }): Promise<boolean>;
28
+ abstract list(userId: string | number, opts?: {
29
+ limit?: number;
30
+ offset?: number;
31
+ includeExpired?: boolean;
32
+ }): Promise<Token[]>;
33
+ abstract close(): Promise<void>;
34
+ normalizeToken(token: Partial<Token>): Token;
35
+ jwtSign(payload: any, secret: string, expiresInSeconds: number, options?: SignOptions): JwtSignResult;
36
+ jwtVerify<T>(token: string, secret: string, options?: VerifyOptions): JwtVerifyResult<T>;
37
+ jwtDecode<T>(token: string, options?: DecodeOptions): JwtDecodeResult<T>;
38
+ }