@technomoron/api-server-base 1.1.13 → 2.0.0-beta.2
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/dist/cjs/api-server-base.cjs +181 -74
- package/dist/cjs/api-server-base.d.ts +66 -29
- package/dist/cjs/auth-api/auth-module.d.ts +96 -0
- package/dist/cjs/auth-api/auth-module.js +1032 -0
- package/dist/cjs/auth-api/compat-auth-storage.d.ts +55 -0
- package/dist/cjs/auth-api/compat-auth-storage.js +116 -0
- package/dist/cjs/auth-api/mem-auth-store.d.ts +66 -0
- package/dist/cjs/auth-api/mem-auth-store.js +135 -0
- package/dist/cjs/{auth-module.d.ts → auth-api/module.d.ts} +7 -7
- package/dist/cjs/{auth-module.cjs → auth-api/module.js} +1 -1
- package/dist/cjs/auth-api/sql-auth-store.d.ts +75 -0
- package/dist/cjs/auth-api/sql-auth-store.js +166 -0
- package/dist/cjs/auth-api/storage.d.ts +36 -0
- package/dist/cjs/{auth-storage.cjs → auth-api/storage.js} +2 -2
- package/dist/cjs/auth-api/types.d.ts +29 -0
- package/dist/cjs/auth-api/types.js +2 -0
- package/dist/cjs/index.cjs +41 -7
- package/dist/cjs/index.d.ts +29 -5
- package/dist/cjs/oauth/base.d.ts +10 -0
- package/dist/cjs/oauth/base.js +6 -0
- package/dist/cjs/oauth/memory.d.ts +16 -0
- package/dist/cjs/oauth/memory.js +99 -0
- package/dist/cjs/oauth/models.d.ts +45 -0
- package/dist/cjs/oauth/models.js +58 -0
- package/dist/cjs/oauth/sequelize.d.ts +68 -0
- package/dist/cjs/oauth/sequelize.js +210 -0
- package/dist/cjs/oauth/types.d.ts +50 -0
- package/dist/cjs/oauth/types.js +3 -0
- package/dist/cjs/passkey/base.d.ts +15 -0
- package/dist/cjs/passkey/base.js +6 -0
- package/dist/cjs/passkey/memory.d.ts +26 -0
- package/dist/cjs/passkey/memory.js +82 -0
- package/dist/cjs/passkey/models.d.ts +25 -0
- package/dist/cjs/passkey/models.js +115 -0
- package/dist/cjs/passkey/sequelize.d.ts +54 -0
- package/dist/cjs/passkey/sequelize.js +211 -0
- package/dist/cjs/passkey/service.d.ts +17 -0
- package/dist/cjs/passkey/service.js +221 -0
- package/dist/cjs/passkey/types.d.ts +75 -0
- package/dist/cjs/passkey/types.js +2 -0
- package/dist/cjs/token/base.d.ts +38 -0
- package/dist/cjs/token/base.js +114 -0
- package/dist/cjs/token/memory.d.ts +19 -0
- package/dist/cjs/token/memory.js +149 -0
- package/dist/cjs/token/sequelize.d.ts +58 -0
- package/dist/cjs/token/sequelize.js +404 -0
- package/dist/cjs/token/types.d.ts +27 -0
- package/dist/cjs/token/types.js +2 -0
- package/dist/cjs/user/base.d.ts +26 -0
- package/dist/cjs/user/base.js +45 -0
- package/dist/cjs/user/memory.d.ts +35 -0
- package/dist/cjs/user/memory.js +173 -0
- package/dist/cjs/user/sequelize.d.ts +41 -0
- package/dist/cjs/user/sequelize.js +182 -0
- package/dist/cjs/user/types.d.ts +11 -0
- package/dist/cjs/user/types.js +2 -0
- package/dist/esm/api-server-base.d.ts +66 -29
- package/dist/esm/api-server-base.js +179 -72
- package/dist/esm/auth-api/auth-module.d.ts +96 -0
- package/dist/esm/auth-api/auth-module.js +1030 -0
- package/dist/esm/auth-api/compat-auth-storage.d.ts +55 -0
- package/dist/esm/auth-api/compat-auth-storage.js +112 -0
- package/dist/esm/auth-api/mem-auth-store.d.ts +66 -0
- package/dist/esm/auth-api/mem-auth-store.js +131 -0
- package/dist/esm/{auth-module.d.ts → auth-api/module.d.ts} +7 -7
- package/dist/esm/{auth-module.js → auth-api/module.js} +1 -1
- package/dist/esm/auth-api/sql-auth-store.d.ts +75 -0
- package/dist/esm/auth-api/sql-auth-store.js +162 -0
- package/dist/esm/auth-api/storage.d.ts +36 -0
- package/dist/esm/{auth-storage.js → auth-api/storage.js} +2 -2
- package/dist/esm/auth-api/types.d.ts +29 -0
- package/dist/esm/auth-api/types.js +1 -0
- package/dist/esm/index.d.ts +29 -5
- package/dist/esm/index.js +19 -2
- package/dist/esm/oauth/base.d.ts +10 -0
- package/dist/esm/oauth/base.js +2 -0
- package/dist/esm/oauth/memory.d.ts +16 -0
- package/dist/esm/oauth/memory.js +92 -0
- package/dist/esm/oauth/models.d.ts +45 -0
- package/dist/esm/oauth/models.js +51 -0
- package/dist/esm/oauth/sequelize.d.ts +68 -0
- package/dist/esm/oauth/sequelize.js +199 -0
- package/dist/esm/oauth/types.d.ts +50 -0
- package/dist/esm/oauth/types.js +2 -0
- package/dist/esm/passkey/base.d.ts +15 -0
- package/dist/esm/passkey/base.js +2 -0
- package/dist/esm/passkey/memory.d.ts +26 -0
- package/dist/esm/passkey/memory.js +78 -0
- package/dist/esm/passkey/models.d.ts +25 -0
- package/dist/esm/passkey/models.js +108 -0
- package/dist/esm/passkey/sequelize.d.ts +54 -0
- package/dist/esm/passkey/sequelize.js +207 -0
- package/dist/esm/passkey/service.d.ts +17 -0
- package/dist/esm/passkey/service.js +217 -0
- package/dist/esm/passkey/types.d.ts +75 -0
- package/dist/esm/passkey/types.js +1 -0
- package/dist/esm/token/base.d.ts +38 -0
- package/dist/esm/token/base.js +107 -0
- package/dist/esm/token/memory.d.ts +19 -0
- package/dist/esm/token/memory.js +145 -0
- package/dist/esm/token/sequelize.d.ts +58 -0
- package/dist/esm/token/sequelize.js +400 -0
- package/dist/esm/token/types.d.ts +27 -0
- package/dist/esm/token/types.js +1 -0
- package/dist/esm/user/base.d.ts +26 -0
- package/dist/esm/user/base.js +38 -0
- package/dist/esm/user/memory.d.ts +35 -0
- package/dist/esm/user/memory.js +169 -0
- package/dist/esm/user/sequelize.d.ts +41 -0
- package/dist/esm/user/sequelize.js +176 -0
- package/dist/esm/user/types.d.ts +11 -0
- package/dist/esm/user/types.js +1 -0
- package/package.json +11 -3
- package/dist/cjs/auth-storage.d.ts +0 -133
- package/dist/esm/auth-storage.d.ts +0 -133
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.SequelizePasskeyStore = void 0;
|
|
4
|
+
const sequelize_1 = require("sequelize");
|
|
5
|
+
const base_js_1 = require("./base.js");
|
|
6
|
+
const DIALECTS_SUPPORTING_UNSIGNED = new Set(['mysql', 'mariadb']);
|
|
7
|
+
function integerIdType(sequelize) {
|
|
8
|
+
return DIALECTS_SUPPORTING_UNSIGNED.has(sequelize.getDialect()) ? sequelize_1.DataTypes.INTEGER.UNSIGNED : sequelize_1.DataTypes.INTEGER;
|
|
9
|
+
}
|
|
10
|
+
function encodeCredentialId(value) {
|
|
11
|
+
return Buffer.isBuffer(value) ? value.toString('base64') : value;
|
|
12
|
+
}
|
|
13
|
+
function normalizeUserId(identifier) {
|
|
14
|
+
if (typeof identifier === 'number' && Number.isFinite(identifier)) {
|
|
15
|
+
return identifier;
|
|
16
|
+
}
|
|
17
|
+
if (typeof identifier === 'string' && /^\d+$/.test(identifier)) {
|
|
18
|
+
return Number(identifier);
|
|
19
|
+
}
|
|
20
|
+
throw new Error(`Unable to normalise user identifier: ${identifier}`);
|
|
21
|
+
}
|
|
22
|
+
class PasskeyCredentialModel extends sequelize_1.Model {
|
|
23
|
+
}
|
|
24
|
+
class PasskeyChallengeModel extends sequelize_1.Model {
|
|
25
|
+
}
|
|
26
|
+
function initPasskeyCredentialModel(sequelize) {
|
|
27
|
+
const idType = integerIdType(sequelize);
|
|
28
|
+
return PasskeyCredentialModel.init({
|
|
29
|
+
credentialId: {
|
|
30
|
+
field: 'credential_id',
|
|
31
|
+
type: sequelize_1.DataTypes.STRING(768),
|
|
32
|
+
primaryKey: true,
|
|
33
|
+
allowNull: false,
|
|
34
|
+
get() {
|
|
35
|
+
const raw = this.getDataValue('credentialId');
|
|
36
|
+
if (!raw) {
|
|
37
|
+
return raw;
|
|
38
|
+
}
|
|
39
|
+
if (Buffer.isBuffer(raw)) {
|
|
40
|
+
return raw;
|
|
41
|
+
}
|
|
42
|
+
return Buffer.from(raw, 'base64');
|
|
43
|
+
},
|
|
44
|
+
set(value) {
|
|
45
|
+
const encoded = typeof value === 'string' ? value : value.toString('base64');
|
|
46
|
+
this.setDataValue('credentialId', encoded);
|
|
47
|
+
}
|
|
48
|
+
},
|
|
49
|
+
userId: {
|
|
50
|
+
field: 'user_id',
|
|
51
|
+
type: idType,
|
|
52
|
+
allowNull: false
|
|
53
|
+
},
|
|
54
|
+
publicKey: {
|
|
55
|
+
field: 'public_key',
|
|
56
|
+
type: sequelize_1.DataTypes.BLOB,
|
|
57
|
+
allowNull: false
|
|
58
|
+
},
|
|
59
|
+
counter: {
|
|
60
|
+
type: sequelize_1.DataTypes.INTEGER,
|
|
61
|
+
allowNull: false,
|
|
62
|
+
defaultValue: 0
|
|
63
|
+
},
|
|
64
|
+
transports: {
|
|
65
|
+
type: sequelize_1.DataTypes.JSON,
|
|
66
|
+
allowNull: true
|
|
67
|
+
},
|
|
68
|
+
backedUp: {
|
|
69
|
+
field: 'backed_up',
|
|
70
|
+
type: sequelize_1.DataTypes.BOOLEAN,
|
|
71
|
+
allowNull: false,
|
|
72
|
+
defaultValue: false
|
|
73
|
+
},
|
|
74
|
+
deviceType: {
|
|
75
|
+
field: 'device_type',
|
|
76
|
+
type: sequelize_1.DataTypes.STRING(32),
|
|
77
|
+
allowNull: false,
|
|
78
|
+
defaultValue: 'multiDevice'
|
|
79
|
+
}
|
|
80
|
+
}, {
|
|
81
|
+
sequelize,
|
|
82
|
+
tableName: 'passkey_credentials',
|
|
83
|
+
timestamps: true,
|
|
84
|
+
underscored: true
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
function initPasskeyChallengeModel(sequelize) {
|
|
88
|
+
const idType = integerIdType(sequelize);
|
|
89
|
+
return PasskeyChallengeModel.init({
|
|
90
|
+
challenge: {
|
|
91
|
+
type: sequelize_1.DataTypes.STRING(255),
|
|
92
|
+
primaryKey: true,
|
|
93
|
+
allowNull: false
|
|
94
|
+
},
|
|
95
|
+
action: {
|
|
96
|
+
type: sequelize_1.DataTypes.STRING(16),
|
|
97
|
+
allowNull: false
|
|
98
|
+
},
|
|
99
|
+
userId: {
|
|
100
|
+
field: 'user_id',
|
|
101
|
+
type: idType,
|
|
102
|
+
allowNull: true
|
|
103
|
+
},
|
|
104
|
+
login: {
|
|
105
|
+
type: sequelize_1.DataTypes.STRING(128),
|
|
106
|
+
allowNull: true
|
|
107
|
+
},
|
|
108
|
+
metadata: {
|
|
109
|
+
type: sequelize_1.DataTypes.JSON,
|
|
110
|
+
allowNull: true
|
|
111
|
+
},
|
|
112
|
+
expiresAt: {
|
|
113
|
+
field: 'expires_at',
|
|
114
|
+
type: sequelize_1.DataTypes.DATE,
|
|
115
|
+
allowNull: false
|
|
116
|
+
}
|
|
117
|
+
}, {
|
|
118
|
+
sequelize,
|
|
119
|
+
tableName: 'passkey_challenges',
|
|
120
|
+
timestamps: true,
|
|
121
|
+
underscored: true,
|
|
122
|
+
indexes: [{ fields: ['expires_at'] }, { fields: ['user_id'] }]
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
class SequelizePasskeyStore extends base_js_1.PasskeyStore {
|
|
126
|
+
constructor(options) {
|
|
127
|
+
super();
|
|
128
|
+
if (!options?.sequelize) {
|
|
129
|
+
throw new Error('SequelizePasskeyStore requires an initialised Sequelize instance');
|
|
130
|
+
}
|
|
131
|
+
this.resolveUserFn = options.resolveUser;
|
|
132
|
+
this.credentials =
|
|
133
|
+
options.credentialModel ??
|
|
134
|
+
(options.credentialModelFactory ?? initPasskeyCredentialModel)(options.sequelize);
|
|
135
|
+
this.challenges =
|
|
136
|
+
options.challengeModel ?? (options.challengeModelFactory ?? initPasskeyChallengeModel)(options.sequelize);
|
|
137
|
+
}
|
|
138
|
+
async resolveUser(params) {
|
|
139
|
+
return this.resolveUserFn(params);
|
|
140
|
+
}
|
|
141
|
+
async listUserCredentials(userId) {
|
|
142
|
+
const models = await this.credentials.findAll({ where: { userId: normalizeUserId(userId) } });
|
|
143
|
+
return models.map((model) => ({
|
|
144
|
+
userId: model.userId,
|
|
145
|
+
credentialId: model.credentialId,
|
|
146
|
+
publicKey: model.publicKey,
|
|
147
|
+
counter: model.counter,
|
|
148
|
+
transports: (model.transports ?? undefined),
|
|
149
|
+
backedUp: model.backedUp,
|
|
150
|
+
deviceType: model.deviceType
|
|
151
|
+
}));
|
|
152
|
+
}
|
|
153
|
+
async findCredentialById(credentialId) {
|
|
154
|
+
const model = await this.credentials.findByPk(encodeCredentialId(credentialId));
|
|
155
|
+
if (!model) {
|
|
156
|
+
return null;
|
|
157
|
+
}
|
|
158
|
+
return {
|
|
159
|
+
userId: model.userId,
|
|
160
|
+
credentialId: model.credentialId,
|
|
161
|
+
publicKey: model.publicKey,
|
|
162
|
+
counter: model.counter,
|
|
163
|
+
transports: (model.transports ?? undefined),
|
|
164
|
+
backedUp: model.backedUp,
|
|
165
|
+
deviceType: model.deviceType
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
async saveCredential(record) {
|
|
169
|
+
await this.credentials.upsert({
|
|
170
|
+
credentialId: record.credentialId,
|
|
171
|
+
userId: normalizeUserId(record.userId),
|
|
172
|
+
publicKey: record.publicKey,
|
|
173
|
+
counter: record.counter,
|
|
174
|
+
transports: record.transports ?? null,
|
|
175
|
+
backedUp: record.backedUp,
|
|
176
|
+
deviceType: record.deviceType
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
async updateCredentialCounter(credentialId, counter) {
|
|
180
|
+
await this.credentials.update({ counter }, { where: { credentialId: encodeCredentialId(credentialId) } });
|
|
181
|
+
}
|
|
182
|
+
async saveChallenge(record) {
|
|
183
|
+
await this.challenges.upsert({
|
|
184
|
+
challenge: record.challenge,
|
|
185
|
+
action: record.action,
|
|
186
|
+
userId: record.userId !== undefined ? normalizeUserId(record.userId) : null,
|
|
187
|
+
login: record.login ?? null,
|
|
188
|
+
metadata: (record.metadata ?? {}),
|
|
189
|
+
expiresAt: record.expiresAt
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
async consumeChallenge(challenge) {
|
|
193
|
+
const model = await this.challenges.findByPk(challenge);
|
|
194
|
+
if (!model) {
|
|
195
|
+
return null;
|
|
196
|
+
}
|
|
197
|
+
await model.destroy();
|
|
198
|
+
return {
|
|
199
|
+
challenge: model.challenge,
|
|
200
|
+
action: model.action,
|
|
201
|
+
userId: model.userId ?? undefined,
|
|
202
|
+
login: model.login ?? undefined,
|
|
203
|
+
expiresAt: model.expiresAt,
|
|
204
|
+
metadata: model.metadata ?? {}
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
async cleanupChallenges(now) {
|
|
208
|
+
await this.challenges.destroy({ where: { expiresAt: { [sequelize_1.Op.lte]: now } } });
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
exports.SequelizePasskeyStore = SequelizePasskeyStore;
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { PasskeyChallenge, PasskeyChallengeParams, PasskeyStorageAdapter, PasskeyVerificationParams, PasskeyVerificationResult, PasskeyServiceConfig } from './types.js';
|
|
2
|
+
export type { CredentialDeviceType, PasskeyChallenge, PasskeyChallengeParams, PasskeyChallengeRecord, PasskeyChallengeMetadata, PasskeyUserDescriptor, PasskeyVerificationParams, PasskeyVerificationResult, PasskeyServiceConfig, PasskeyStorageAdapter } from './types.js';
|
|
3
|
+
type Logger = Pick<typeof console, 'error' | 'warn'>;
|
|
4
|
+
export declare class PasskeyService {
|
|
5
|
+
private readonly config;
|
|
6
|
+
private readonly adapter;
|
|
7
|
+
private readonly logger;
|
|
8
|
+
constructor(config: PasskeyServiceConfig, adapter: PasskeyStorageAdapter, logger?: Logger);
|
|
9
|
+
createChallenge(params: PasskeyChallengeParams): Promise<PasskeyChallenge>;
|
|
10
|
+
verifyResponse(params: PasskeyVerificationParams): Promise<PasskeyVerificationResult>;
|
|
11
|
+
private createRegistrationChallenge;
|
|
12
|
+
private createAuthenticationChallenge;
|
|
13
|
+
private verifyRegistration;
|
|
14
|
+
private verifyAuthentication;
|
|
15
|
+
private requireUser;
|
|
16
|
+
private createExpiry;
|
|
17
|
+
}
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.PasskeyService = void 0;
|
|
4
|
+
const server_1 = require("@simplewebauthn/server");
|
|
5
|
+
const helpers_1 = require("@simplewebauthn/server/helpers");
|
|
6
|
+
const ALLOWED_TRANSPORTS = [
|
|
7
|
+
'ble',
|
|
8
|
+
'cable',
|
|
9
|
+
'hybrid',
|
|
10
|
+
'internal',
|
|
11
|
+
'nfc',
|
|
12
|
+
'smart-card',
|
|
13
|
+
'usb'
|
|
14
|
+
];
|
|
15
|
+
function sanitizeTransports(input) {
|
|
16
|
+
if (!Array.isArray(input)) {
|
|
17
|
+
return undefined;
|
|
18
|
+
}
|
|
19
|
+
const filtered = input
|
|
20
|
+
.map((value) => String(value))
|
|
21
|
+
.filter((value) => ALLOWED_TRANSPORTS.includes(value));
|
|
22
|
+
return filtered.length > 0 ? filtered : undefined;
|
|
23
|
+
}
|
|
24
|
+
function toBase64Url(buffer) {
|
|
25
|
+
return helpers_1.isoBase64URL.fromBuffer(new Uint8Array(buffer));
|
|
26
|
+
}
|
|
27
|
+
function fromBase64Url(value) {
|
|
28
|
+
return Buffer.from(helpers_1.isoBase64URL.toBuffer(value));
|
|
29
|
+
}
|
|
30
|
+
function toBuffer(value) {
|
|
31
|
+
if (Buffer.isBuffer(value)) {
|
|
32
|
+
return value;
|
|
33
|
+
}
|
|
34
|
+
const view = value instanceof Uint8Array ? value : new Uint8Array(value);
|
|
35
|
+
return Buffer.from(view);
|
|
36
|
+
}
|
|
37
|
+
class PasskeyService {
|
|
38
|
+
constructor(config, adapter, logger = console) {
|
|
39
|
+
this.config = config;
|
|
40
|
+
this.adapter = adapter;
|
|
41
|
+
this.logger = logger;
|
|
42
|
+
}
|
|
43
|
+
async createChallenge(params) {
|
|
44
|
+
await this.adapter.cleanupChallenges?.(new Date());
|
|
45
|
+
const metadata = {
|
|
46
|
+
domain: typeof params.domain === 'string' ? params.domain : undefined,
|
|
47
|
+
fingerprint: typeof params.fingerprint === 'string' ? params.fingerprint : undefined,
|
|
48
|
+
label: typeof params.label === 'string' ? params.label : undefined,
|
|
49
|
+
userAgent: typeof params.userAgent === 'string' ? params.userAgent : undefined
|
|
50
|
+
};
|
|
51
|
+
if (params.action === 'register') {
|
|
52
|
+
return this.createRegistrationChallenge(params, metadata);
|
|
53
|
+
}
|
|
54
|
+
if (params.action === 'authenticate') {
|
|
55
|
+
return this.createAuthenticationChallenge(params, metadata);
|
|
56
|
+
}
|
|
57
|
+
throw new Error(`Unsupported passkey action: ${String(params.action)}`);
|
|
58
|
+
}
|
|
59
|
+
async verifyResponse(params) {
|
|
60
|
+
await this.adapter.cleanupChallenges?.(new Date());
|
|
61
|
+
const record = await this.adapter.consumeChallenge(params.expectedChallenge);
|
|
62
|
+
if (!record) {
|
|
63
|
+
return { verified: false };
|
|
64
|
+
}
|
|
65
|
+
if (record.expiresAt.getTime() <= Date.now()) {
|
|
66
|
+
return { verified: false };
|
|
67
|
+
}
|
|
68
|
+
try {
|
|
69
|
+
if (record.action === 'register') {
|
|
70
|
+
return this.verifyRegistration(params, record);
|
|
71
|
+
}
|
|
72
|
+
if (record.action === 'authenticate') {
|
|
73
|
+
return this.verifyAuthentication(params, record);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
catch (error) {
|
|
77
|
+
this.logger.error?.('Passkey verification failed', error);
|
|
78
|
+
}
|
|
79
|
+
return { verified: false };
|
|
80
|
+
}
|
|
81
|
+
async createRegistrationChallenge(params, metadata) {
|
|
82
|
+
const user = await this.requireUser({ userId: params.userId, login: params.login });
|
|
83
|
+
const existing = await this.adapter.listUserCredentials(user.id);
|
|
84
|
+
const excludeCredentials = existing.map((credential) => {
|
|
85
|
+
const transports = credential.transports;
|
|
86
|
+
return transports && transports.length > 0
|
|
87
|
+
? { id: toBase64Url(credential.credentialId), transports }
|
|
88
|
+
: { id: toBase64Url(credential.credentialId) };
|
|
89
|
+
});
|
|
90
|
+
const options = await (0, server_1.generateRegistrationOptions)({
|
|
91
|
+
rpName: this.config.rpName,
|
|
92
|
+
rpID: this.config.rpId,
|
|
93
|
+
userID: Buffer.from(String(user.id)),
|
|
94
|
+
userName: user.login,
|
|
95
|
+
userDisplayName: user.displayName,
|
|
96
|
+
excludeCredentials
|
|
97
|
+
});
|
|
98
|
+
const expiresAt = this.createExpiry();
|
|
99
|
+
await this.adapter.saveChallenge({
|
|
100
|
+
challenge: options.challenge,
|
|
101
|
+
action: 'register',
|
|
102
|
+
userId: user.id,
|
|
103
|
+
login: user.login,
|
|
104
|
+
expiresAt,
|
|
105
|
+
metadata
|
|
106
|
+
});
|
|
107
|
+
return {
|
|
108
|
+
challenge: options.challenge,
|
|
109
|
+
expiresAt: expiresAt.toISOString(),
|
|
110
|
+
userId: user.id
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
async createAuthenticationChallenge(params, metadata) {
|
|
114
|
+
const user = await this.requireUser({ userId: params.userId, login: params.login });
|
|
115
|
+
const credentials = await this.adapter.listUserCredentials(user.id);
|
|
116
|
+
const allowCredentials = credentials.map((credential) => {
|
|
117
|
+
const transports = sanitizeTransports(credential.transports);
|
|
118
|
+
return transports && transports.length > 0
|
|
119
|
+
? { type: 'public-key', id: toBase64Url(credential.credentialId), transports }
|
|
120
|
+
: { type: 'public-key', id: toBase64Url(credential.credentialId) };
|
|
121
|
+
});
|
|
122
|
+
const options = await (0, server_1.generateAuthenticationOptions)({
|
|
123
|
+
allowCredentials,
|
|
124
|
+
userVerification: this.config.userVerification,
|
|
125
|
+
rpID: this.config.rpId
|
|
126
|
+
});
|
|
127
|
+
const expiresAt = this.createExpiry();
|
|
128
|
+
await this.adapter.saveChallenge({
|
|
129
|
+
challenge: options.challenge,
|
|
130
|
+
action: 'authenticate',
|
|
131
|
+
userId: user.id,
|
|
132
|
+
login: user.login,
|
|
133
|
+
expiresAt,
|
|
134
|
+
metadata
|
|
135
|
+
});
|
|
136
|
+
return {
|
|
137
|
+
challenge: options.challenge,
|
|
138
|
+
expiresAt: expiresAt.toISOString(),
|
|
139
|
+
userId: user.id
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
async verifyRegistration(params, record) {
|
|
143
|
+
const parsed = typeof params.response === 'object' && params.response ? { ...params.response } : {};
|
|
144
|
+
const response = {
|
|
145
|
+
...parsed,
|
|
146
|
+
id: String(parsed.id ?? ''),
|
|
147
|
+
rawId: String(parsed.rawId ?? '')
|
|
148
|
+
};
|
|
149
|
+
const user = await this.requireUser({ userId: record.userId, login: record.login });
|
|
150
|
+
const result = await (0, server_1.verifyRegistrationResponse)({
|
|
151
|
+
response,
|
|
152
|
+
expectedChallenge: record.challenge,
|
|
153
|
+
expectedOrigin: this.config.origins,
|
|
154
|
+
expectedRPID: this.config.rpId,
|
|
155
|
+
requireUserVerification: true
|
|
156
|
+
});
|
|
157
|
+
if (!result.verified || !result.registrationInfo) {
|
|
158
|
+
return { verified: false };
|
|
159
|
+
}
|
|
160
|
+
const registrationInfo = result.registrationInfo;
|
|
161
|
+
await this.adapter.saveCredential({
|
|
162
|
+
userId: user.id,
|
|
163
|
+
credentialId: toBuffer(registrationInfo.credentialID),
|
|
164
|
+
publicKey: toBuffer(registrationInfo.credentialPublicKey),
|
|
165
|
+
counter: registrationInfo.counter,
|
|
166
|
+
transports: sanitizeTransports(params.response.transports),
|
|
167
|
+
backedUp: registrationInfo.credentialDeviceType === 'multiDevice',
|
|
168
|
+
deviceType: registrationInfo.credentialDeviceType
|
|
169
|
+
});
|
|
170
|
+
return { verified: true, userId: user.id, login: user.login };
|
|
171
|
+
}
|
|
172
|
+
async verifyAuthentication(params, record) {
|
|
173
|
+
const parsed = typeof params.response === 'object' && params.response ? { ...params.response } : {};
|
|
174
|
+
const response = {
|
|
175
|
+
...parsed,
|
|
176
|
+
id: String(parsed.id ?? ''),
|
|
177
|
+
rawId: String(parsed.rawId ?? '')
|
|
178
|
+
};
|
|
179
|
+
const credential = await this.adapter.findCredentialById(fromBase64Url(response.id));
|
|
180
|
+
if (!credential) {
|
|
181
|
+
return { verified: false };
|
|
182
|
+
}
|
|
183
|
+
const user = await this.requireUser({ userId: credential.userId, login: record.login });
|
|
184
|
+
const storedAuthData = {
|
|
185
|
+
credentialID: credential.credentialId,
|
|
186
|
+
counter: credential.counter,
|
|
187
|
+
credentialBackedUp: credential.backedUp,
|
|
188
|
+
credentialDeviceType: credential.deviceType,
|
|
189
|
+
credentialPublicKey: credential.publicKey,
|
|
190
|
+
transports: credential.transports ?? undefined
|
|
191
|
+
};
|
|
192
|
+
const result = await (0, server_1.verifyAuthenticationResponse)({
|
|
193
|
+
response,
|
|
194
|
+
expectedChallenge: record.challenge,
|
|
195
|
+
expectedOrigin: this.config.origins,
|
|
196
|
+
expectedRPID: this.config.rpId,
|
|
197
|
+
authenticator: storedAuthData,
|
|
198
|
+
requireUserVerification: true
|
|
199
|
+
});
|
|
200
|
+
if (!result.verified) {
|
|
201
|
+
return { verified: false };
|
|
202
|
+
}
|
|
203
|
+
await this.adapter.updateCredentialCounter(credential.credentialId, result.authenticationInfo.newCounter);
|
|
204
|
+
return {
|
|
205
|
+
verified: true,
|
|
206
|
+
userId: user.id,
|
|
207
|
+
login: user.login
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
async requireUser(params) {
|
|
211
|
+
const user = await this.adapter.resolveUser(params);
|
|
212
|
+
if (!user) {
|
|
213
|
+
throw new Error('User not found');
|
|
214
|
+
}
|
|
215
|
+
return user;
|
|
216
|
+
}
|
|
217
|
+
createExpiry() {
|
|
218
|
+
return new Date(Date.now() + this.config.timeoutMs);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
exports.PasskeyService = PasskeyService;
|
|
@@ -0,0 +1,75 @@
|
|
|
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
|
+
}
|
|
40
|
+
export interface PasskeyStorageAdapter {
|
|
41
|
+
resolveUser(params: {
|
|
42
|
+
userId?: AuthIdentifier;
|
|
43
|
+
login?: string;
|
|
44
|
+
}): Promise<PasskeyUserDescriptor | null>;
|
|
45
|
+
listUserCredentials(userId: AuthIdentifier): Promise<StoredPasskeyCredential[]>;
|
|
46
|
+
findCredentialById(credentialId: Buffer): Promise<StoredPasskeyCredential | null>;
|
|
47
|
+
saveCredential(record: StoredPasskeyCredential): Promise<void>;
|
|
48
|
+
updateCredentialCounter(credentialId: Buffer, counter: number): Promise<void>;
|
|
49
|
+
saveChallenge(record: PasskeyChallengeRecord): Promise<void>;
|
|
50
|
+
consumeChallenge(challenge: string): Promise<PasskeyChallengeRecord | null>;
|
|
51
|
+
cleanupChallenges?(now: Date): Promise<void>;
|
|
52
|
+
}
|
|
53
|
+
export interface PasskeyChallengeParams extends Partial<Omit<Token, 'userId'>> {
|
|
54
|
+
action: 'register' | 'authenticate';
|
|
55
|
+
login?: string;
|
|
56
|
+
userAgent?: string;
|
|
57
|
+
userId?: AuthIdentifier;
|
|
58
|
+
}
|
|
59
|
+
export interface PasskeyChallenge extends Record<string, unknown> {
|
|
60
|
+
challenge: string;
|
|
61
|
+
expiresAt?: string | number | Date;
|
|
62
|
+
userId?: AuthIdentifier;
|
|
63
|
+
}
|
|
64
|
+
export interface PasskeyVerificationParams extends Partial<Omit<Token, 'userId'>> {
|
|
65
|
+
expectedChallenge: string;
|
|
66
|
+
login?: string;
|
|
67
|
+
response: Record<string, unknown>;
|
|
68
|
+
userId?: AuthIdentifier;
|
|
69
|
+
}
|
|
70
|
+
export interface PasskeyVerificationResult extends Record<string, unknown> {
|
|
71
|
+
login?: string;
|
|
72
|
+
tokens?: TokenPair;
|
|
73
|
+
userId?: AuthIdentifier;
|
|
74
|
+
verified: boolean;
|
|
75
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.TokenStore = void 0;
|
|
7
|
+
const jsonwebtoken_1 = __importDefault(require("jsonwebtoken"));
|
|
8
|
+
function normalizeScope(scope) {
|
|
9
|
+
if (!scope) {
|
|
10
|
+
return undefined;
|
|
11
|
+
}
|
|
12
|
+
if (Array.isArray(scope)) {
|
|
13
|
+
return scope.filter((entry) => typeof entry === 'string' && entry.length > 0);
|
|
14
|
+
}
|
|
15
|
+
return scope
|
|
16
|
+
.split(/\s+/)
|
|
17
|
+
.map((entry) => entry.trim())
|
|
18
|
+
.filter((entry) => entry.length > 0);
|
|
19
|
+
}
|
|
20
|
+
function normalizeRefreshTtlSeconds(value) {
|
|
21
|
+
if (typeof value === 'number' && Number.isFinite(value) && value > 0) {
|
|
22
|
+
return Math.floor(value);
|
|
23
|
+
}
|
|
24
|
+
if (typeof value === 'string') {
|
|
25
|
+
const parsed = Number(value);
|
|
26
|
+
if (Number.isFinite(parsed) && parsed > 0) {
|
|
27
|
+
return Math.floor(parsed);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
return undefined;
|
|
31
|
+
}
|
|
32
|
+
function normalizeTokenInternal(input) {
|
|
33
|
+
if (!input.refreshToken) {
|
|
34
|
+
throw new Error('refreshToken is required');
|
|
35
|
+
}
|
|
36
|
+
if (!input.accessToken) {
|
|
37
|
+
throw new Error('accessToken is required');
|
|
38
|
+
}
|
|
39
|
+
if (input.userId === undefined || input.userId === null) {
|
|
40
|
+
throw new Error('userId is required');
|
|
41
|
+
}
|
|
42
|
+
const userId = String(input.userId);
|
|
43
|
+
const ruid = input.ruid === undefined || input.ruid === null ? undefined : String(input.ruid);
|
|
44
|
+
const expires = input.expires ? new Date(input.expires) : new Date(Date.now() + 30 * 24 * 60 * 60 * 1000);
|
|
45
|
+
const issuedAt = input.issuedAt ? new Date(input.issuedAt) : new Date();
|
|
46
|
+
const lastSeenAt = input.lastSeenAt ? new Date(input.lastSeenAt) : issuedAt;
|
|
47
|
+
const scope = normalizeScope(input.scope);
|
|
48
|
+
const refreshTtlSeconds = normalizeRefreshTtlSeconds(input.refreshTtlSeconds);
|
|
49
|
+
const status = input.status === 'revoked' ? 'revoked' : expires.getTime() < Date.now() ? 'expired' : 'active';
|
|
50
|
+
const sessionCookie = typeof input.sessionCookie === 'boolean' ? input.sessionCookie : false;
|
|
51
|
+
return {
|
|
52
|
+
...input,
|
|
53
|
+
accessToken: input.accessToken,
|
|
54
|
+
refreshToken: input.refreshToken,
|
|
55
|
+
userId,
|
|
56
|
+
domain: typeof input.domain === 'string' ? input.domain : '',
|
|
57
|
+
fingerprint: typeof input.fingerprint === 'string' ? input.fingerprint : '',
|
|
58
|
+
label: typeof input.label === 'string' ? input.label : '',
|
|
59
|
+
browser: typeof input.browser === 'string' ? input.browser : '',
|
|
60
|
+
device: typeof input.device === 'string' ? input.device : '',
|
|
61
|
+
ip: typeof input.ip === 'string' ? input.ip : '',
|
|
62
|
+
os: typeof input.os === 'string' ? input.os : '',
|
|
63
|
+
loginType: typeof input.loginType === 'string' && input.loginType.length > 0 ? input.loginType : undefined,
|
|
64
|
+
scope,
|
|
65
|
+
refreshTtlSeconds,
|
|
66
|
+
expires,
|
|
67
|
+
issuedAt,
|
|
68
|
+
lastSeenAt,
|
|
69
|
+
status,
|
|
70
|
+
ruid,
|
|
71
|
+
sessionCookie
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
class TokenStore {
|
|
75
|
+
// Instance helpers
|
|
76
|
+
normalizeToken(token) {
|
|
77
|
+
return normalizeTokenInternal(token);
|
|
78
|
+
}
|
|
79
|
+
jwtSign(payload, secret, expiresInSeconds, options) {
|
|
80
|
+
const opts = { ...(options ?? {}), expiresIn: expiresInSeconds };
|
|
81
|
+
try {
|
|
82
|
+
const token = jsonwebtoken_1.default.sign(payload, secret, opts);
|
|
83
|
+
return { success: true, token };
|
|
84
|
+
}
|
|
85
|
+
catch (error) {
|
|
86
|
+
return { success: false, error: error instanceof Error ? error.message : String(error) };
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
jwtVerify(token, secret, options) {
|
|
90
|
+
try {
|
|
91
|
+
const data = jsonwebtoken_1.default.verify(token, secret, options ?? {});
|
|
92
|
+
return { success: true, data };
|
|
93
|
+
}
|
|
94
|
+
catch (error) {
|
|
95
|
+
if (error instanceof jsonwebtoken_1.default.TokenExpiredError) {
|
|
96
|
+
return { success: false, expired: true, error: 'Token expired' };
|
|
97
|
+
}
|
|
98
|
+
return { success: false, expired: false, error: error instanceof Error ? error.message : String(error) };
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
jwtDecode(token, options) {
|
|
102
|
+
try {
|
|
103
|
+
const data = jsonwebtoken_1.default.decode(token, options ?? {});
|
|
104
|
+
if (data === null) {
|
|
105
|
+
return { success: false, error: 'Invalid token format' };
|
|
106
|
+
}
|
|
107
|
+
return { success: true, data };
|
|
108
|
+
}
|
|
109
|
+
catch (error) {
|
|
110
|
+
return { success: false, error: error instanceof Error ? error.message : String(error) };
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
exports.TokenStore = TokenStore;
|