@technomoron/api-server-base 2.0.0-beta.10 → 2.0.0-beta.12
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 +8 -2
- package/dist/cjs/auth-api/auth-module.js +36 -16
- package/dist/cjs/passkey/memory.js +5 -3
- package/dist/cjs/passkey/models.d.ts +7 -2
- package/dist/cjs/passkey/models.js +34 -4
- package/dist/cjs/passkey/sequelize.d.ts +7 -1
- package/dist/cjs/passkey/sequelize.js +57 -8
- package/dist/cjs/passkey/service.d.ts +1 -1
- package/dist/cjs/passkey/service.js +21 -15
- package/dist/cjs/passkey/types.d.ts +9 -9
- package/dist/esm/api-server-base.js +8 -2
- package/dist/esm/auth-api/auth-module.js +36 -16
- package/dist/esm/passkey/memory.js +5 -3
- package/dist/esm/passkey/models.d.ts +7 -2
- package/dist/esm/passkey/models.js +34 -4
- package/dist/esm/passkey/sequelize.d.ts +7 -1
- package/dist/esm/passkey/sequelize.js +57 -8
- package/dist/esm/passkey/service.d.ts +1 -1
- package/dist/esm/passkey/service.js +21 -15
- package/dist/esm/passkey/types.d.ts +9 -9
- package/package.json +11 -11
|
@@ -619,6 +619,7 @@ class ApiServer {
|
|
|
619
619
|
const path = `${this.apiBasePath}/v1/ping`;
|
|
620
620
|
this.app.get(path, (_req, res) => {
|
|
621
621
|
const payload = {
|
|
622
|
+
success: true,
|
|
622
623
|
status: 'ok',
|
|
623
624
|
apiVersion: this.config.apiVersion ?? '',
|
|
624
625
|
minClientVersion: this.config.minClientVersion ?? '',
|
|
@@ -626,7 +627,7 @@ class ApiServer {
|
|
|
626
627
|
startedAt: this.startedAt,
|
|
627
628
|
timestamp: new Date().toISOString()
|
|
628
629
|
};
|
|
629
|
-
res.status(200).json({ code: 200, message: 'Success', data: payload });
|
|
630
|
+
res.status(200).json({ success: true, code: 200, message: 'Success', data: payload, errors: {} });
|
|
630
631
|
});
|
|
631
632
|
}
|
|
632
633
|
normalizeApiBasePath(path) {
|
|
@@ -649,6 +650,7 @@ class ApiServer {
|
|
|
649
650
|
}
|
|
650
651
|
this.apiNotFoundHandler = (req, res) => {
|
|
651
652
|
const payload = {
|
|
653
|
+
success: false,
|
|
652
654
|
code: 404,
|
|
653
655
|
message: this.describeMissingEndpoint(req),
|
|
654
656
|
data: null,
|
|
@@ -1068,6 +1070,7 @@ class ApiServer {
|
|
|
1068
1070
|
? apiError.errors
|
|
1069
1071
|
: {};
|
|
1070
1072
|
const errorPayload = {
|
|
1073
|
+
success: false,
|
|
1071
1074
|
code: apiError.code,
|
|
1072
1075
|
message: apiError.message,
|
|
1073
1076
|
data: apiError.data ?? null,
|
|
@@ -1077,6 +1080,7 @@ class ApiServer {
|
|
|
1077
1080
|
return;
|
|
1078
1081
|
}
|
|
1079
1082
|
const errorPayload = {
|
|
1083
|
+
success: false,
|
|
1080
1084
|
code: 500,
|
|
1081
1085
|
message: this.guessExceptionText(error),
|
|
1082
1086
|
data: null,
|
|
@@ -1111,7 +1115,7 @@ class ApiServer {
|
|
|
1111
1115
|
throw new ApiError({ code: 500, message: 'Handler result must start with a numeric status code' });
|
|
1112
1116
|
}
|
|
1113
1117
|
const message = typeof rawMessage === 'string' ? rawMessage : 'Success';
|
|
1114
|
-
const responsePayload = { code, message, data };
|
|
1118
|
+
const responsePayload = { success: true, code, message, data, errors: {} };
|
|
1115
1119
|
if (this.config.debug) {
|
|
1116
1120
|
this.dumpResponse(apiReq, responsePayload, code);
|
|
1117
1121
|
}
|
|
@@ -1124,6 +1128,7 @@ class ApiServer {
|
|
|
1124
1128
|
? apiError.errors
|
|
1125
1129
|
: {};
|
|
1126
1130
|
const errorPayload = {
|
|
1131
|
+
success: false,
|
|
1127
1132
|
code: apiError.code,
|
|
1128
1133
|
message: apiError.message,
|
|
1129
1134
|
data: apiError.data ?? null,
|
|
@@ -1136,6 +1141,7 @@ class ApiServer {
|
|
|
1136
1141
|
}
|
|
1137
1142
|
else {
|
|
1138
1143
|
const errorPayload = {
|
|
1144
|
+
success: false,
|
|
1139
1145
|
code: 500,
|
|
1140
1146
|
message: this.guessExceptionText(error),
|
|
1141
1147
|
data: null,
|
|
@@ -565,7 +565,28 @@ class AuthModule extends module_js_1.BaseAuthModule {
|
|
|
565
565
|
: cookiePrefs.refreshTtlSeconds;
|
|
566
566
|
this.setJwtCookies(apiReq, { accessToken: access.token, refreshToken }, { sessionCookie: cookiePrefs.sessionCookie ?? false, refreshTtlSeconds: refreshTtlForCookie });
|
|
567
567
|
}
|
|
568
|
-
|
|
568
|
+
const tokenClaims = verify.data;
|
|
569
|
+
const effectiveUserId = this.storage.getUserId(user);
|
|
570
|
+
const effectiveId = String(effectiveUserId);
|
|
571
|
+
const rawRealId = stored.ruid ?? tokenClaims.ruid;
|
|
572
|
+
const normalizedRealId = rawRealId === undefined || rawRealId === null ? null : String(rawRealId).trim() || null;
|
|
573
|
+
const isImpersonating = normalizedRealId !== null && normalizedRealId !== effectiveId;
|
|
574
|
+
let realUser;
|
|
575
|
+
let realUserId;
|
|
576
|
+
if (isImpersonating && normalizedRealId !== null) {
|
|
577
|
+
const realUserEntity = await this.getUserOrThrow(normalizedRealId, 'Real user not found');
|
|
578
|
+
realUser = this.storage.filterUser(realUserEntity);
|
|
579
|
+
realUserId = this.storage.getUserId(realUserEntity);
|
|
580
|
+
}
|
|
581
|
+
return [
|
|
582
|
+
200,
|
|
583
|
+
{
|
|
584
|
+
user: this.storage.filterUser(user),
|
|
585
|
+
isImpersonating,
|
|
586
|
+
realUser,
|
|
587
|
+
realUserId
|
|
588
|
+
}
|
|
589
|
+
];
|
|
569
590
|
}
|
|
570
591
|
async postPasskeyChallenge(apiReq) {
|
|
571
592
|
if (typeof this.storage.createPasskeyChallenge !== 'function') {
|
|
@@ -579,15 +600,7 @@ class AuthModule extends module_js_1.BaseAuthModule {
|
|
|
579
600
|
const params = {
|
|
580
601
|
action,
|
|
581
602
|
login: toStringOrNull(body.login) ?? undefined,
|
|
582
|
-
userId: isAuthIdentifier(body.userId) ? body.userId : undefined
|
|
583
|
-
userAgent: toStringOrNull(body.userAgent) ?? undefined,
|
|
584
|
-
domain: toStringOrNull(body.domain) ?? undefined,
|
|
585
|
-
fingerprint: toStringOrNull(body.fingerprint) ?? undefined,
|
|
586
|
-
label: toStringOrNull(body.label) ?? undefined,
|
|
587
|
-
browser: toStringOrNull(body.browser) ?? undefined,
|
|
588
|
-
device: toStringOrNull(body.device) ?? undefined,
|
|
589
|
-
ip: toStringOrNull(body.ip) ?? undefined,
|
|
590
|
-
os: toStringOrNull(body.os) ?? undefined
|
|
603
|
+
userId: isAuthIdentifier(body.userId) ? body.userId : undefined
|
|
591
604
|
};
|
|
592
605
|
const challenge = await this.storage.createPasskeyChallenge(params);
|
|
593
606
|
return [200, challenge];
|
|
@@ -603,18 +616,25 @@ class AuthModule extends module_js_1.BaseAuthModule {
|
|
|
603
616
|
if (!expectedChallenge || typeof response !== 'object' || response === null) {
|
|
604
617
|
throw new api_server_base_js_1.ApiError({ code: 400, message: 'Malformed passkey verification payload' });
|
|
605
618
|
}
|
|
606
|
-
const
|
|
607
|
-
expectedChallenge,
|
|
608
|
-
response: response,
|
|
609
|
-
login: toStringOrNull(body.login) ?? undefined,
|
|
610
|
-
userId: isAuthIdentifier(body.userId) ? body.userId : undefined,
|
|
619
|
+
const rawMetadata = {
|
|
611
620
|
domain: toStringOrNull(body.domain) ?? undefined,
|
|
612
621
|
fingerprint: toStringOrNull(body.fingerprint) ?? undefined,
|
|
613
622
|
label: toStringOrNull(body.label) ?? undefined,
|
|
614
623
|
browser: toStringOrNull(body.browser) ?? undefined,
|
|
615
624
|
device: toStringOrNull(body.device) ?? undefined,
|
|
616
625
|
ip: toStringOrNull(body.ip) ?? undefined,
|
|
617
|
-
os: toStringOrNull(body.os) ?? undefined
|
|
626
|
+
os: toStringOrNull(body.os) ?? undefined
|
|
627
|
+
};
|
|
628
|
+
const clientInfo = apiReq.getClientInfo();
|
|
629
|
+
const userAgent = toStringOrNull(body.userAgent) ?? (clientInfo.ua ? clientInfo.ua : null);
|
|
630
|
+
const requestMetadata = this.enrichTokenMetadata(apiReq, rawMetadata);
|
|
631
|
+
const params = {
|
|
632
|
+
expectedChallenge,
|
|
633
|
+
response: response,
|
|
634
|
+
login: toStringOrNull(body.login) ?? undefined,
|
|
635
|
+
userId: isAuthIdentifier(body.userId) ? body.userId : undefined,
|
|
636
|
+
userAgent: userAgent ?? undefined,
|
|
637
|
+
...requestMetadata,
|
|
618
638
|
...sessionPrefs
|
|
619
639
|
};
|
|
620
640
|
const result = await this.storage.verifyPasskeyResponse(params);
|
|
@@ -62,9 +62,11 @@ class MemoryPasskeyStore extends base_js_1.PasskeyStore {
|
|
|
62
62
|
}
|
|
63
63
|
async saveChallenge(record) {
|
|
64
64
|
this.challenges.set(record.challenge, {
|
|
65
|
-
|
|
65
|
+
challenge: record.challenge,
|
|
66
|
+
action: record.action,
|
|
66
67
|
userId: record.userId !== undefined ? normalizeUserId(record.userId) : undefined,
|
|
67
|
-
|
|
68
|
+
login: record.login ?? undefined,
|
|
69
|
+
expiresAt: record.expiresAt
|
|
68
70
|
});
|
|
69
71
|
}
|
|
70
72
|
async consumeChallenge(challenge) {
|
|
@@ -73,7 +75,7 @@ class MemoryPasskeyStore extends base_js_1.PasskeyStore {
|
|
|
73
75
|
return null;
|
|
74
76
|
}
|
|
75
77
|
this.challenges.delete(challenge);
|
|
76
|
-
return { ...record
|
|
78
|
+
return { ...record };
|
|
77
79
|
}
|
|
78
80
|
async cleanupChallenges(now) {
|
|
79
81
|
for (const [challenge, record] of this.challenges.entries()) {
|
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import { Model, type InferAttributes, type InferCreationAttributes, type ModelStatic, type Sequelize } from 'sequelize';
|
|
2
|
-
import type { PasskeyChallengeMetadata } from './service.js';
|
|
3
2
|
export declare class PasskeyCredentialModel extends Model<InferAttributes<PasskeyCredentialModel>, InferCreationAttributes<PasskeyCredentialModel>> {
|
|
4
3
|
credentialId: Buffer;
|
|
5
4
|
userId: number;
|
|
@@ -8,6 +7,13 @@ export declare class PasskeyCredentialModel extends Model<InferAttributes<Passke
|
|
|
8
7
|
transports: string[] | null;
|
|
9
8
|
backedUp: boolean;
|
|
10
9
|
deviceType: string;
|
|
10
|
+
label: string | null;
|
|
11
|
+
createdDomain: string | null;
|
|
12
|
+
createdUserAgent: string | null;
|
|
13
|
+
createdBrowser: string | null;
|
|
14
|
+
createdOs: string | null;
|
|
15
|
+
createdDevice: string | null;
|
|
16
|
+
createdIp: string | null;
|
|
11
17
|
createdAt?: Date;
|
|
12
18
|
updatedAt?: Date;
|
|
13
19
|
}
|
|
@@ -16,7 +22,6 @@ export declare class PasskeyChallengeModel extends Model<InferAttributes<Passkey
|
|
|
16
22
|
action: 'register' | 'authenticate';
|
|
17
23
|
userId: number | null;
|
|
18
24
|
login: string | null;
|
|
19
|
-
metadata: PasskeyChallengeMetadata | null;
|
|
20
25
|
expiresAt: Date;
|
|
21
26
|
createdAt?: Date;
|
|
22
27
|
updatedAt?: Date;
|
|
@@ -67,6 +67,40 @@ function initPasskeyCredentialModel(sequelize) {
|
|
|
67
67
|
type: sequelize_1.DataTypes.STRING(32),
|
|
68
68
|
allowNull: false,
|
|
69
69
|
defaultValue: 'multiDevice'
|
|
70
|
+
},
|
|
71
|
+
label: {
|
|
72
|
+
type: sequelize_1.DataTypes.STRING(120),
|
|
73
|
+
allowNull: true
|
|
74
|
+
},
|
|
75
|
+
createdDomain: {
|
|
76
|
+
field: 'created_domain',
|
|
77
|
+
type: sequelize_1.DataTypes.STRING(255),
|
|
78
|
+
allowNull: true
|
|
79
|
+
},
|
|
80
|
+
createdUserAgent: {
|
|
81
|
+
field: 'created_user_agent',
|
|
82
|
+
type: sequelize_1.DataTypes.TEXT,
|
|
83
|
+
allowNull: true
|
|
84
|
+
},
|
|
85
|
+
createdBrowser: {
|
|
86
|
+
field: 'created_browser',
|
|
87
|
+
type: sequelize_1.DataTypes.STRING(120),
|
|
88
|
+
allowNull: true
|
|
89
|
+
},
|
|
90
|
+
createdOs: {
|
|
91
|
+
field: 'created_os',
|
|
92
|
+
type: sequelize_1.DataTypes.STRING(120),
|
|
93
|
+
allowNull: true
|
|
94
|
+
},
|
|
95
|
+
createdDevice: {
|
|
96
|
+
field: 'created_device',
|
|
97
|
+
type: sequelize_1.DataTypes.STRING(120),
|
|
98
|
+
allowNull: true
|
|
99
|
+
},
|
|
100
|
+
createdIp: {
|
|
101
|
+
field: 'created_ip',
|
|
102
|
+
type: sequelize_1.DataTypes.STRING(45),
|
|
103
|
+
allowNull: true
|
|
70
104
|
}
|
|
71
105
|
}, {
|
|
72
106
|
sequelize,
|
|
@@ -96,10 +130,6 @@ function initPasskeyChallengeModel(sequelize) {
|
|
|
96
130
|
type: sequelize_1.DataTypes.STRING(128),
|
|
97
131
|
allowNull: true
|
|
98
132
|
},
|
|
99
|
-
metadata: {
|
|
100
|
-
type: sequelize_1.DataTypes.JSON,
|
|
101
|
-
allowNull: true
|
|
102
|
-
},
|
|
103
133
|
expiresAt: {
|
|
104
134
|
field: 'expires_at',
|
|
105
135
|
type: sequelize_1.DataTypes.DATE,
|
|
@@ -10,6 +10,13 @@ declare class PasskeyCredentialModel extends Model<InferAttributes<PasskeyCreden
|
|
|
10
10
|
transports: string[] | null;
|
|
11
11
|
backedUp: boolean;
|
|
12
12
|
deviceType: string;
|
|
13
|
+
label: string | null;
|
|
14
|
+
createdDomain: string | null;
|
|
15
|
+
createdUserAgent: string | null;
|
|
16
|
+
createdBrowser: string | null;
|
|
17
|
+
createdOs: string | null;
|
|
18
|
+
createdDevice: string | null;
|
|
19
|
+
createdIp: string | null;
|
|
13
20
|
createdAt?: Date;
|
|
14
21
|
updatedAt?: Date;
|
|
15
22
|
}
|
|
@@ -18,7 +25,6 @@ declare class PasskeyChallengeModel extends Model<InferAttributes<PasskeyChallen
|
|
|
18
25
|
action: 'register' | 'authenticate';
|
|
19
26
|
userId: number | null;
|
|
20
27
|
login: string | null;
|
|
21
|
-
metadata: Record<string, unknown> | null;
|
|
22
28
|
expiresAt: Date;
|
|
23
29
|
createdAt?: Date;
|
|
24
30
|
updatedAt?: Date;
|
|
@@ -76,6 +76,40 @@ function initPasskeyCredentialModel(sequelize) {
|
|
|
76
76
|
type: sequelize_1.DataTypes.STRING(32),
|
|
77
77
|
allowNull: false,
|
|
78
78
|
defaultValue: 'multiDevice'
|
|
79
|
+
},
|
|
80
|
+
label: {
|
|
81
|
+
type: sequelize_1.DataTypes.STRING(120),
|
|
82
|
+
allowNull: true
|
|
83
|
+
},
|
|
84
|
+
createdDomain: {
|
|
85
|
+
field: 'created_domain',
|
|
86
|
+
type: sequelize_1.DataTypes.STRING(255),
|
|
87
|
+
allowNull: true
|
|
88
|
+
},
|
|
89
|
+
createdUserAgent: {
|
|
90
|
+
field: 'created_user_agent',
|
|
91
|
+
type: sequelize_1.DataTypes.TEXT,
|
|
92
|
+
allowNull: true
|
|
93
|
+
},
|
|
94
|
+
createdBrowser: {
|
|
95
|
+
field: 'created_browser',
|
|
96
|
+
type: sequelize_1.DataTypes.STRING(120),
|
|
97
|
+
allowNull: true
|
|
98
|
+
},
|
|
99
|
+
createdOs: {
|
|
100
|
+
field: 'created_os',
|
|
101
|
+
type: sequelize_1.DataTypes.STRING(120),
|
|
102
|
+
allowNull: true
|
|
103
|
+
},
|
|
104
|
+
createdDevice: {
|
|
105
|
+
field: 'created_device',
|
|
106
|
+
type: sequelize_1.DataTypes.STRING(120),
|
|
107
|
+
allowNull: true
|
|
108
|
+
},
|
|
109
|
+
createdIp: {
|
|
110
|
+
field: 'created_ip',
|
|
111
|
+
type: sequelize_1.DataTypes.STRING(45),
|
|
112
|
+
allowNull: true
|
|
79
113
|
}
|
|
80
114
|
}, {
|
|
81
115
|
sequelize,
|
|
@@ -105,10 +139,6 @@ function initPasskeyChallengeModel(sequelize) {
|
|
|
105
139
|
type: sequelize_1.DataTypes.STRING(128),
|
|
106
140
|
allowNull: true
|
|
107
141
|
},
|
|
108
|
-
metadata: {
|
|
109
|
-
type: sequelize_1.DataTypes.JSON,
|
|
110
|
-
allowNull: true
|
|
111
|
-
},
|
|
112
142
|
expiresAt: {
|
|
113
143
|
field: 'expires_at',
|
|
114
144
|
type: sequelize_1.DataTypes.DATE,
|
|
@@ -148,6 +178,13 @@ class SequelizePasskeyStore extends base_js_1.PasskeyStore {
|
|
|
148
178
|
transports: (model.transports ?? undefined),
|
|
149
179
|
backedUp: model.backedUp,
|
|
150
180
|
deviceType: model.deviceType,
|
|
181
|
+
label: model.label ?? undefined,
|
|
182
|
+
createdDomain: model.createdDomain ?? undefined,
|
|
183
|
+
createdUserAgent: model.createdUserAgent ?? undefined,
|
|
184
|
+
createdBrowser: model.createdBrowser ?? undefined,
|
|
185
|
+
createdOs: model.createdOs ?? undefined,
|
|
186
|
+
createdDevice: model.createdDevice ?? undefined,
|
|
187
|
+
createdIp: model.createdIp ?? undefined,
|
|
151
188
|
createdAt: model.createdAt ?? undefined,
|
|
152
189
|
updatedAt: model.updatedAt ?? undefined
|
|
153
190
|
}));
|
|
@@ -170,6 +207,13 @@ class SequelizePasskeyStore extends base_js_1.PasskeyStore {
|
|
|
170
207
|
transports: (model.transports ?? undefined),
|
|
171
208
|
backedUp: model.backedUp,
|
|
172
209
|
deviceType: model.deviceType,
|
|
210
|
+
label: model.label ?? undefined,
|
|
211
|
+
createdDomain: model.createdDomain ?? undefined,
|
|
212
|
+
createdUserAgent: model.createdUserAgent ?? undefined,
|
|
213
|
+
createdBrowser: model.createdBrowser ?? undefined,
|
|
214
|
+
createdOs: model.createdOs ?? undefined,
|
|
215
|
+
createdDevice: model.createdDevice ?? undefined,
|
|
216
|
+
createdIp: model.createdIp ?? undefined,
|
|
173
217
|
createdAt: model.createdAt ?? undefined,
|
|
174
218
|
updatedAt: model.updatedAt ?? undefined
|
|
175
219
|
};
|
|
@@ -182,7 +226,14 @@ class SequelizePasskeyStore extends base_js_1.PasskeyStore {
|
|
|
182
226
|
counter: record.counter,
|
|
183
227
|
transports: record.transports ?? null,
|
|
184
228
|
backedUp: record.backedUp,
|
|
185
|
-
deviceType: record.deviceType
|
|
229
|
+
deviceType: record.deviceType,
|
|
230
|
+
label: record.label ?? null,
|
|
231
|
+
createdDomain: record.createdDomain ?? null,
|
|
232
|
+
createdUserAgent: record.createdUserAgent ?? null,
|
|
233
|
+
createdBrowser: record.createdBrowser ?? null,
|
|
234
|
+
createdOs: record.createdOs ?? null,
|
|
235
|
+
createdDevice: record.createdDevice ?? null,
|
|
236
|
+
createdIp: record.createdIp ?? null
|
|
186
237
|
});
|
|
187
238
|
}
|
|
188
239
|
async updateCredentialCounter(credentialId, counter) {
|
|
@@ -194,7 +245,6 @@ class SequelizePasskeyStore extends base_js_1.PasskeyStore {
|
|
|
194
245
|
action: record.action,
|
|
195
246
|
userId: record.userId !== undefined ? normalizeUserId(record.userId) : null,
|
|
196
247
|
login: record.login ?? null,
|
|
197
|
-
metadata: (record.metadata ?? {}),
|
|
198
248
|
expiresAt: record.expiresAt
|
|
199
249
|
});
|
|
200
250
|
}
|
|
@@ -209,8 +259,7 @@ class SequelizePasskeyStore extends base_js_1.PasskeyStore {
|
|
|
209
259
|
action: model.action,
|
|
210
260
|
userId: model.userId ?? undefined,
|
|
211
261
|
login: model.login ?? undefined,
|
|
212
|
-
expiresAt: model.expiresAt
|
|
213
|
-
metadata: model.metadata ?? {}
|
|
262
|
+
expiresAt: model.expiresAt
|
|
214
263
|
};
|
|
215
264
|
}
|
|
216
265
|
async cleanupChallenges(now) {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { PasskeyChallenge, PasskeyChallengeParams, PasskeyStorageAdapter, PasskeyVerificationParams, PasskeyVerificationResult, PasskeyServiceConfig, StoredPasskeyCredential } from './types.js';
|
|
2
2
|
import type { AuthIdentifier } from '../auth-api/types.js';
|
|
3
|
-
export type { CredentialDeviceType, PasskeyChallenge, PasskeyChallengeParams, PasskeyChallengeRecord,
|
|
3
|
+
export type { CredentialDeviceType, PasskeyChallenge, PasskeyChallengeParams, PasskeyChallengeRecord, PasskeyUserDescriptor, PasskeyVerificationParams, PasskeyVerificationResult, PasskeyServiceConfig, PasskeyStorageAdapter, StoredPasskeyCredential } from './types.js';
|
|
4
4
|
type Logger = Pick<typeof console, 'error' | 'warn'>;
|
|
5
5
|
export declare class PasskeyService {
|
|
6
6
|
private readonly config;
|
|
@@ -54,6 +54,13 @@ function sanitizeTransports(input) {
|
|
|
54
54
|
.filter((value) => ALLOWED_TRANSPORTS.includes(value));
|
|
55
55
|
return filtered.length > 0 ? filtered : undefined;
|
|
56
56
|
}
|
|
57
|
+
function toOptionalString(value) {
|
|
58
|
+
if (typeof value !== 'string') {
|
|
59
|
+
return undefined;
|
|
60
|
+
}
|
|
61
|
+
const trimmed = value.trim();
|
|
62
|
+
return trimmed.length > 0 ? trimmed : undefined;
|
|
63
|
+
}
|
|
57
64
|
function toBase64Url(buffer) {
|
|
58
65
|
return helpers_1.isoBase64URL.fromBuffer(new Uint8Array(buffer));
|
|
59
66
|
}
|
|
@@ -127,17 +134,11 @@ class PasskeyService {
|
|
|
127
134
|
}
|
|
128
135
|
async createChallenge(params) {
|
|
129
136
|
await this.adapter.cleanupChallenges?.(new Date());
|
|
130
|
-
const metadata = {
|
|
131
|
-
domain: typeof params.domain === 'string' ? params.domain : undefined,
|
|
132
|
-
fingerprint: typeof params.fingerprint === 'string' ? params.fingerprint : undefined,
|
|
133
|
-
label: typeof params.label === 'string' ? params.label : undefined,
|
|
134
|
-
userAgent: typeof params.userAgent === 'string' ? params.userAgent : undefined
|
|
135
|
-
};
|
|
136
137
|
if (params.action === 'register') {
|
|
137
|
-
return this.createRegistrationChallenge(params
|
|
138
|
+
return this.createRegistrationChallenge(params);
|
|
138
139
|
}
|
|
139
140
|
if (params.action === 'authenticate') {
|
|
140
|
-
return this.createAuthenticationChallenge(params
|
|
141
|
+
return this.createAuthenticationChallenge(params);
|
|
141
142
|
}
|
|
142
143
|
throw new Error(`Unsupported passkey action: ${String(params.action)}`);
|
|
143
144
|
}
|
|
@@ -163,7 +164,7 @@ class PasskeyService {
|
|
|
163
164
|
}
|
|
164
165
|
return { verified: false };
|
|
165
166
|
}
|
|
166
|
-
async createRegistrationChallenge(params
|
|
167
|
+
async createRegistrationChallenge(params) {
|
|
167
168
|
const user = await this.requireUser({ userId: params.userId, login: params.login });
|
|
168
169
|
const existing = await this.adapter.listUserCredentials(user.id);
|
|
169
170
|
const excludeCredentials = existing.map((credential) => {
|
|
@@ -186,8 +187,7 @@ class PasskeyService {
|
|
|
186
187
|
action: 'register',
|
|
187
188
|
userId: user.id,
|
|
188
189
|
login: user.login,
|
|
189
|
-
expiresAt
|
|
190
|
-
metadata
|
|
190
|
+
expiresAt
|
|
191
191
|
});
|
|
192
192
|
return {
|
|
193
193
|
challenge: options.challenge,
|
|
@@ -196,7 +196,7 @@ class PasskeyService {
|
|
|
196
196
|
publicKey: options
|
|
197
197
|
};
|
|
198
198
|
}
|
|
199
|
-
async createAuthenticationChallenge(params
|
|
199
|
+
async createAuthenticationChallenge(params) {
|
|
200
200
|
const user = await this.requireUser({ userId: params.userId, login: params.login });
|
|
201
201
|
const credentials = await this.adapter.listUserCredentials(user.id);
|
|
202
202
|
const allowCredentials = credentials.map((credential) => {
|
|
@@ -216,8 +216,7 @@ class PasskeyService {
|
|
|
216
216
|
action: 'authenticate',
|
|
217
217
|
userId: user.id,
|
|
218
218
|
login: user.login,
|
|
219
|
-
expiresAt
|
|
220
|
-
metadata
|
|
219
|
+
expiresAt
|
|
221
220
|
});
|
|
222
221
|
return {
|
|
223
222
|
challenge: options.challenge,
|
|
@@ -295,7 +294,14 @@ class PasskeyService {
|
|
|
295
294
|
counter: registrationInfo.counter ?? 0,
|
|
296
295
|
transports: sanitizeTransports(params.response.transports),
|
|
297
296
|
backedUp: registrationInfo.credentialDeviceType === 'multiDevice',
|
|
298
|
-
deviceType: registrationInfo.credentialDeviceType
|
|
297
|
+
deviceType: registrationInfo.credentialDeviceType,
|
|
298
|
+
label: toOptionalString(params.label),
|
|
299
|
+
createdDomain: toOptionalString(params.domain),
|
|
300
|
+
createdUserAgent: toOptionalString(params.userAgent),
|
|
301
|
+
createdBrowser: toOptionalString(params.browser),
|
|
302
|
+
createdOs: toOptionalString(params.os),
|
|
303
|
+
createdDevice: toOptionalString(params.device),
|
|
304
|
+
createdIp: toOptionalString(params.ip)
|
|
299
305
|
});
|
|
300
306
|
return { verified: true, userId: user.id, login: user.login };
|
|
301
307
|
}
|
|
@@ -9,19 +9,12 @@ export interface PasskeyServiceConfig {
|
|
|
9
9
|
timeoutMs: number;
|
|
10
10
|
userVerification?: 'preferred' | 'required' | 'discouraged';
|
|
11
11
|
}
|
|
12
|
-
export interface PasskeyChallengeMetadata {
|
|
13
|
-
domain?: string;
|
|
14
|
-
fingerprint?: string;
|
|
15
|
-
label?: string;
|
|
16
|
-
userAgent?: string;
|
|
17
|
-
}
|
|
18
12
|
export interface PasskeyChallengeRecord {
|
|
19
13
|
challenge: string;
|
|
20
14
|
action: 'register' | 'authenticate';
|
|
21
15
|
userId?: AuthIdentifier;
|
|
22
16
|
login?: string;
|
|
23
17
|
expiresAt: Date;
|
|
24
|
-
metadata: PasskeyChallengeMetadata;
|
|
25
18
|
}
|
|
26
19
|
export interface PasskeyUserDescriptor {
|
|
27
20
|
id: AuthIdentifier;
|
|
@@ -36,6 +29,13 @@ export interface StoredPasskeyCredential {
|
|
|
36
29
|
transports?: AuthenticatorTransportFuture[];
|
|
37
30
|
backedUp: boolean;
|
|
38
31
|
deviceType: CredentialDeviceType;
|
|
32
|
+
label?: string;
|
|
33
|
+
createdDomain?: string;
|
|
34
|
+
createdUserAgent?: string;
|
|
35
|
+
createdBrowser?: string;
|
|
36
|
+
createdOs?: string;
|
|
37
|
+
createdDevice?: string;
|
|
38
|
+
createdIp?: string;
|
|
39
39
|
createdAt?: Date;
|
|
40
40
|
updatedAt?: Date;
|
|
41
41
|
}
|
|
@@ -53,10 +53,9 @@ export interface PasskeyStorageAdapter {
|
|
|
53
53
|
consumeChallenge(challenge: string): Promise<PasskeyChallengeRecord | null>;
|
|
54
54
|
cleanupChallenges?(now: Date): Promise<void>;
|
|
55
55
|
}
|
|
56
|
-
export interface PasskeyChallengeParams
|
|
56
|
+
export interface PasskeyChallengeParams {
|
|
57
57
|
action: 'register' | 'authenticate';
|
|
58
58
|
login?: string;
|
|
59
|
-
userAgent?: string;
|
|
60
59
|
userId?: AuthIdentifier;
|
|
61
60
|
}
|
|
62
61
|
export interface PasskeyChallenge extends Record<string, unknown> {
|
|
@@ -69,6 +68,7 @@ export interface PasskeyVerificationParams extends Partial<Omit<Token, 'userId'>
|
|
|
69
68
|
login?: string;
|
|
70
69
|
response: Record<string, unknown>;
|
|
71
70
|
userId?: AuthIdentifier;
|
|
71
|
+
userAgent?: string;
|
|
72
72
|
}
|
|
73
73
|
export interface PasskeyVerificationResult extends Record<string, unknown> {
|
|
74
74
|
login?: string;
|
|
@@ -611,6 +611,7 @@ export class ApiServer {
|
|
|
611
611
|
const path = `${this.apiBasePath}/v1/ping`;
|
|
612
612
|
this.app.get(path, (_req, res) => {
|
|
613
613
|
const payload = {
|
|
614
|
+
success: true,
|
|
614
615
|
status: 'ok',
|
|
615
616
|
apiVersion: this.config.apiVersion ?? '',
|
|
616
617
|
minClientVersion: this.config.minClientVersion ?? '',
|
|
@@ -618,7 +619,7 @@ export class ApiServer {
|
|
|
618
619
|
startedAt: this.startedAt,
|
|
619
620
|
timestamp: new Date().toISOString()
|
|
620
621
|
};
|
|
621
|
-
res.status(200).json({ code: 200, message: 'Success', data: payload });
|
|
622
|
+
res.status(200).json({ success: true, code: 200, message: 'Success', data: payload, errors: {} });
|
|
622
623
|
});
|
|
623
624
|
}
|
|
624
625
|
normalizeApiBasePath(path) {
|
|
@@ -641,6 +642,7 @@ export class ApiServer {
|
|
|
641
642
|
}
|
|
642
643
|
this.apiNotFoundHandler = (req, res) => {
|
|
643
644
|
const payload = {
|
|
645
|
+
success: false,
|
|
644
646
|
code: 404,
|
|
645
647
|
message: this.describeMissingEndpoint(req),
|
|
646
648
|
data: null,
|
|
@@ -1060,6 +1062,7 @@ export class ApiServer {
|
|
|
1060
1062
|
? apiError.errors
|
|
1061
1063
|
: {};
|
|
1062
1064
|
const errorPayload = {
|
|
1065
|
+
success: false,
|
|
1063
1066
|
code: apiError.code,
|
|
1064
1067
|
message: apiError.message,
|
|
1065
1068
|
data: apiError.data ?? null,
|
|
@@ -1069,6 +1072,7 @@ export class ApiServer {
|
|
|
1069
1072
|
return;
|
|
1070
1073
|
}
|
|
1071
1074
|
const errorPayload = {
|
|
1075
|
+
success: false,
|
|
1072
1076
|
code: 500,
|
|
1073
1077
|
message: this.guessExceptionText(error),
|
|
1074
1078
|
data: null,
|
|
@@ -1103,7 +1107,7 @@ export class ApiServer {
|
|
|
1103
1107
|
throw new ApiError({ code: 500, message: 'Handler result must start with a numeric status code' });
|
|
1104
1108
|
}
|
|
1105
1109
|
const message = typeof rawMessage === 'string' ? rawMessage : 'Success';
|
|
1106
|
-
const responsePayload = { code, message, data };
|
|
1110
|
+
const responsePayload = { success: true, code, message, data, errors: {} };
|
|
1107
1111
|
if (this.config.debug) {
|
|
1108
1112
|
this.dumpResponse(apiReq, responsePayload, code);
|
|
1109
1113
|
}
|
|
@@ -1116,6 +1120,7 @@ export class ApiServer {
|
|
|
1116
1120
|
? apiError.errors
|
|
1117
1121
|
: {};
|
|
1118
1122
|
const errorPayload = {
|
|
1123
|
+
success: false,
|
|
1119
1124
|
code: apiError.code,
|
|
1120
1125
|
message: apiError.message,
|
|
1121
1126
|
data: apiError.data ?? null,
|
|
@@ -1128,6 +1133,7 @@ export class ApiServer {
|
|
|
1128
1133
|
}
|
|
1129
1134
|
else {
|
|
1130
1135
|
const errorPayload = {
|
|
1136
|
+
success: false,
|
|
1131
1137
|
code: 500,
|
|
1132
1138
|
message: this.guessExceptionText(error),
|
|
1133
1139
|
data: null,
|
|
@@ -563,7 +563,28 @@ class AuthModule extends BaseAuthModule {
|
|
|
563
563
|
: cookiePrefs.refreshTtlSeconds;
|
|
564
564
|
this.setJwtCookies(apiReq, { accessToken: access.token, refreshToken }, { sessionCookie: cookiePrefs.sessionCookie ?? false, refreshTtlSeconds: refreshTtlForCookie });
|
|
565
565
|
}
|
|
566
|
-
|
|
566
|
+
const tokenClaims = verify.data;
|
|
567
|
+
const effectiveUserId = this.storage.getUserId(user);
|
|
568
|
+
const effectiveId = String(effectiveUserId);
|
|
569
|
+
const rawRealId = stored.ruid ?? tokenClaims.ruid;
|
|
570
|
+
const normalizedRealId = rawRealId === undefined || rawRealId === null ? null : String(rawRealId).trim() || null;
|
|
571
|
+
const isImpersonating = normalizedRealId !== null && normalizedRealId !== effectiveId;
|
|
572
|
+
let realUser;
|
|
573
|
+
let realUserId;
|
|
574
|
+
if (isImpersonating && normalizedRealId !== null) {
|
|
575
|
+
const realUserEntity = await this.getUserOrThrow(normalizedRealId, 'Real user not found');
|
|
576
|
+
realUser = this.storage.filterUser(realUserEntity);
|
|
577
|
+
realUserId = this.storage.getUserId(realUserEntity);
|
|
578
|
+
}
|
|
579
|
+
return [
|
|
580
|
+
200,
|
|
581
|
+
{
|
|
582
|
+
user: this.storage.filterUser(user),
|
|
583
|
+
isImpersonating,
|
|
584
|
+
realUser,
|
|
585
|
+
realUserId
|
|
586
|
+
}
|
|
587
|
+
];
|
|
567
588
|
}
|
|
568
589
|
async postPasskeyChallenge(apiReq) {
|
|
569
590
|
if (typeof this.storage.createPasskeyChallenge !== 'function') {
|
|
@@ -577,15 +598,7 @@ class AuthModule extends BaseAuthModule {
|
|
|
577
598
|
const params = {
|
|
578
599
|
action,
|
|
579
600
|
login: toStringOrNull(body.login) ?? undefined,
|
|
580
|
-
userId: isAuthIdentifier(body.userId) ? body.userId : undefined
|
|
581
|
-
userAgent: toStringOrNull(body.userAgent) ?? undefined,
|
|
582
|
-
domain: toStringOrNull(body.domain) ?? undefined,
|
|
583
|
-
fingerprint: toStringOrNull(body.fingerprint) ?? undefined,
|
|
584
|
-
label: toStringOrNull(body.label) ?? undefined,
|
|
585
|
-
browser: toStringOrNull(body.browser) ?? undefined,
|
|
586
|
-
device: toStringOrNull(body.device) ?? undefined,
|
|
587
|
-
ip: toStringOrNull(body.ip) ?? undefined,
|
|
588
|
-
os: toStringOrNull(body.os) ?? undefined
|
|
601
|
+
userId: isAuthIdentifier(body.userId) ? body.userId : undefined
|
|
589
602
|
};
|
|
590
603
|
const challenge = await this.storage.createPasskeyChallenge(params);
|
|
591
604
|
return [200, challenge];
|
|
@@ -601,18 +614,25 @@ class AuthModule extends BaseAuthModule {
|
|
|
601
614
|
if (!expectedChallenge || typeof response !== 'object' || response === null) {
|
|
602
615
|
throw new ApiError({ code: 400, message: 'Malformed passkey verification payload' });
|
|
603
616
|
}
|
|
604
|
-
const
|
|
605
|
-
expectedChallenge,
|
|
606
|
-
response: response,
|
|
607
|
-
login: toStringOrNull(body.login) ?? undefined,
|
|
608
|
-
userId: isAuthIdentifier(body.userId) ? body.userId : undefined,
|
|
617
|
+
const rawMetadata = {
|
|
609
618
|
domain: toStringOrNull(body.domain) ?? undefined,
|
|
610
619
|
fingerprint: toStringOrNull(body.fingerprint) ?? undefined,
|
|
611
620
|
label: toStringOrNull(body.label) ?? undefined,
|
|
612
621
|
browser: toStringOrNull(body.browser) ?? undefined,
|
|
613
622
|
device: toStringOrNull(body.device) ?? undefined,
|
|
614
623
|
ip: toStringOrNull(body.ip) ?? undefined,
|
|
615
|
-
os: toStringOrNull(body.os) ?? undefined
|
|
624
|
+
os: toStringOrNull(body.os) ?? undefined
|
|
625
|
+
};
|
|
626
|
+
const clientInfo = apiReq.getClientInfo();
|
|
627
|
+
const userAgent = toStringOrNull(body.userAgent) ?? (clientInfo.ua ? clientInfo.ua : null);
|
|
628
|
+
const requestMetadata = this.enrichTokenMetadata(apiReq, rawMetadata);
|
|
629
|
+
const params = {
|
|
630
|
+
expectedChallenge,
|
|
631
|
+
response: response,
|
|
632
|
+
login: toStringOrNull(body.login) ?? undefined,
|
|
633
|
+
userId: isAuthIdentifier(body.userId) ? body.userId : undefined,
|
|
634
|
+
userAgent: userAgent ?? undefined,
|
|
635
|
+
...requestMetadata,
|
|
616
636
|
...sessionPrefs
|
|
617
637
|
};
|
|
618
638
|
const result = await this.storage.verifyPasskeyResponse(params);
|
|
@@ -59,9 +59,11 @@ export class MemoryPasskeyStore extends PasskeyStore {
|
|
|
59
59
|
}
|
|
60
60
|
async saveChallenge(record) {
|
|
61
61
|
this.challenges.set(record.challenge, {
|
|
62
|
-
|
|
62
|
+
challenge: record.challenge,
|
|
63
|
+
action: record.action,
|
|
63
64
|
userId: record.userId !== undefined ? normalizeUserId(record.userId) : undefined,
|
|
64
|
-
|
|
65
|
+
login: record.login ?? undefined,
|
|
66
|
+
expiresAt: record.expiresAt
|
|
65
67
|
});
|
|
66
68
|
}
|
|
67
69
|
async consumeChallenge(challenge) {
|
|
@@ -70,7 +72,7 @@ export class MemoryPasskeyStore extends PasskeyStore {
|
|
|
70
72
|
return null;
|
|
71
73
|
}
|
|
72
74
|
this.challenges.delete(challenge);
|
|
73
|
-
return { ...record
|
|
75
|
+
return { ...record };
|
|
74
76
|
}
|
|
75
77
|
async cleanupChallenges(now) {
|
|
76
78
|
for (const [challenge, record] of this.challenges.entries()) {
|
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import { Model, type InferAttributes, type InferCreationAttributes, type ModelStatic, type Sequelize } from 'sequelize';
|
|
2
|
-
import type { PasskeyChallengeMetadata } from './service.js';
|
|
3
2
|
export declare class PasskeyCredentialModel extends Model<InferAttributes<PasskeyCredentialModel>, InferCreationAttributes<PasskeyCredentialModel>> {
|
|
4
3
|
credentialId: Buffer;
|
|
5
4
|
userId: number;
|
|
@@ -8,6 +7,13 @@ export declare class PasskeyCredentialModel extends Model<InferAttributes<Passke
|
|
|
8
7
|
transports: string[] | null;
|
|
9
8
|
backedUp: boolean;
|
|
10
9
|
deviceType: string;
|
|
10
|
+
label: string | null;
|
|
11
|
+
createdDomain: string | null;
|
|
12
|
+
createdUserAgent: string | null;
|
|
13
|
+
createdBrowser: string | null;
|
|
14
|
+
createdOs: string | null;
|
|
15
|
+
createdDevice: string | null;
|
|
16
|
+
createdIp: string | null;
|
|
11
17
|
createdAt?: Date;
|
|
12
18
|
updatedAt?: Date;
|
|
13
19
|
}
|
|
@@ -16,7 +22,6 @@ export declare class PasskeyChallengeModel extends Model<InferAttributes<Passkey
|
|
|
16
22
|
action: 'register' | 'authenticate';
|
|
17
23
|
userId: number | null;
|
|
18
24
|
login: string | null;
|
|
19
|
-
metadata: PasskeyChallengeMetadata | null;
|
|
20
25
|
expiresAt: Date;
|
|
21
26
|
createdAt?: Date;
|
|
22
27
|
updatedAt?: Date;
|
|
@@ -60,6 +60,40 @@ export function initPasskeyCredentialModel(sequelize) {
|
|
|
60
60
|
type: DataTypes.STRING(32),
|
|
61
61
|
allowNull: false,
|
|
62
62
|
defaultValue: 'multiDevice'
|
|
63
|
+
},
|
|
64
|
+
label: {
|
|
65
|
+
type: DataTypes.STRING(120),
|
|
66
|
+
allowNull: true
|
|
67
|
+
},
|
|
68
|
+
createdDomain: {
|
|
69
|
+
field: 'created_domain',
|
|
70
|
+
type: DataTypes.STRING(255),
|
|
71
|
+
allowNull: true
|
|
72
|
+
},
|
|
73
|
+
createdUserAgent: {
|
|
74
|
+
field: 'created_user_agent',
|
|
75
|
+
type: DataTypes.TEXT,
|
|
76
|
+
allowNull: true
|
|
77
|
+
},
|
|
78
|
+
createdBrowser: {
|
|
79
|
+
field: 'created_browser',
|
|
80
|
+
type: DataTypes.STRING(120),
|
|
81
|
+
allowNull: true
|
|
82
|
+
},
|
|
83
|
+
createdOs: {
|
|
84
|
+
field: 'created_os',
|
|
85
|
+
type: DataTypes.STRING(120),
|
|
86
|
+
allowNull: true
|
|
87
|
+
},
|
|
88
|
+
createdDevice: {
|
|
89
|
+
field: 'created_device',
|
|
90
|
+
type: DataTypes.STRING(120),
|
|
91
|
+
allowNull: true
|
|
92
|
+
},
|
|
93
|
+
createdIp: {
|
|
94
|
+
field: 'created_ip',
|
|
95
|
+
type: DataTypes.STRING(45),
|
|
96
|
+
allowNull: true
|
|
63
97
|
}
|
|
64
98
|
}, {
|
|
65
99
|
sequelize,
|
|
@@ -89,10 +123,6 @@ export function initPasskeyChallengeModel(sequelize) {
|
|
|
89
123
|
type: DataTypes.STRING(128),
|
|
90
124
|
allowNull: true
|
|
91
125
|
},
|
|
92
|
-
metadata: {
|
|
93
|
-
type: DataTypes.JSON,
|
|
94
|
-
allowNull: true
|
|
95
|
-
},
|
|
96
126
|
expiresAt: {
|
|
97
127
|
field: 'expires_at',
|
|
98
128
|
type: DataTypes.DATE,
|
|
@@ -10,6 +10,13 @@ declare class PasskeyCredentialModel extends Model<InferAttributes<PasskeyCreden
|
|
|
10
10
|
transports: string[] | null;
|
|
11
11
|
backedUp: boolean;
|
|
12
12
|
deviceType: string;
|
|
13
|
+
label: string | null;
|
|
14
|
+
createdDomain: string | null;
|
|
15
|
+
createdUserAgent: string | null;
|
|
16
|
+
createdBrowser: string | null;
|
|
17
|
+
createdOs: string | null;
|
|
18
|
+
createdDevice: string | null;
|
|
19
|
+
createdIp: string | null;
|
|
13
20
|
createdAt?: Date;
|
|
14
21
|
updatedAt?: Date;
|
|
15
22
|
}
|
|
@@ -18,7 +25,6 @@ declare class PasskeyChallengeModel extends Model<InferAttributes<PasskeyChallen
|
|
|
18
25
|
action: 'register' | 'authenticate';
|
|
19
26
|
userId: number | null;
|
|
20
27
|
login: string | null;
|
|
21
|
-
metadata: Record<string, unknown> | null;
|
|
22
28
|
expiresAt: Date;
|
|
23
29
|
createdAt?: Date;
|
|
24
30
|
updatedAt?: Date;
|
|
@@ -73,6 +73,40 @@ function initPasskeyCredentialModel(sequelize) {
|
|
|
73
73
|
type: DataTypes.STRING(32),
|
|
74
74
|
allowNull: false,
|
|
75
75
|
defaultValue: 'multiDevice'
|
|
76
|
+
},
|
|
77
|
+
label: {
|
|
78
|
+
type: DataTypes.STRING(120),
|
|
79
|
+
allowNull: true
|
|
80
|
+
},
|
|
81
|
+
createdDomain: {
|
|
82
|
+
field: 'created_domain',
|
|
83
|
+
type: DataTypes.STRING(255),
|
|
84
|
+
allowNull: true
|
|
85
|
+
},
|
|
86
|
+
createdUserAgent: {
|
|
87
|
+
field: 'created_user_agent',
|
|
88
|
+
type: DataTypes.TEXT,
|
|
89
|
+
allowNull: true
|
|
90
|
+
},
|
|
91
|
+
createdBrowser: {
|
|
92
|
+
field: 'created_browser',
|
|
93
|
+
type: DataTypes.STRING(120),
|
|
94
|
+
allowNull: true
|
|
95
|
+
},
|
|
96
|
+
createdOs: {
|
|
97
|
+
field: 'created_os',
|
|
98
|
+
type: DataTypes.STRING(120),
|
|
99
|
+
allowNull: true
|
|
100
|
+
},
|
|
101
|
+
createdDevice: {
|
|
102
|
+
field: 'created_device',
|
|
103
|
+
type: DataTypes.STRING(120),
|
|
104
|
+
allowNull: true
|
|
105
|
+
},
|
|
106
|
+
createdIp: {
|
|
107
|
+
field: 'created_ip',
|
|
108
|
+
type: DataTypes.STRING(45),
|
|
109
|
+
allowNull: true
|
|
76
110
|
}
|
|
77
111
|
}, {
|
|
78
112
|
sequelize,
|
|
@@ -102,10 +136,6 @@ function initPasskeyChallengeModel(sequelize) {
|
|
|
102
136
|
type: DataTypes.STRING(128),
|
|
103
137
|
allowNull: true
|
|
104
138
|
},
|
|
105
|
-
metadata: {
|
|
106
|
-
type: DataTypes.JSON,
|
|
107
|
-
allowNull: true
|
|
108
|
-
},
|
|
109
139
|
expiresAt: {
|
|
110
140
|
field: 'expires_at',
|
|
111
141
|
type: DataTypes.DATE,
|
|
@@ -145,6 +175,13 @@ export class SequelizePasskeyStore extends PasskeyStore {
|
|
|
145
175
|
transports: (model.transports ?? undefined),
|
|
146
176
|
backedUp: model.backedUp,
|
|
147
177
|
deviceType: model.deviceType,
|
|
178
|
+
label: model.label ?? undefined,
|
|
179
|
+
createdDomain: model.createdDomain ?? undefined,
|
|
180
|
+
createdUserAgent: model.createdUserAgent ?? undefined,
|
|
181
|
+
createdBrowser: model.createdBrowser ?? undefined,
|
|
182
|
+
createdOs: model.createdOs ?? undefined,
|
|
183
|
+
createdDevice: model.createdDevice ?? undefined,
|
|
184
|
+
createdIp: model.createdIp ?? undefined,
|
|
148
185
|
createdAt: model.createdAt ?? undefined,
|
|
149
186
|
updatedAt: model.updatedAt ?? undefined
|
|
150
187
|
}));
|
|
@@ -167,6 +204,13 @@ export class SequelizePasskeyStore extends PasskeyStore {
|
|
|
167
204
|
transports: (model.transports ?? undefined),
|
|
168
205
|
backedUp: model.backedUp,
|
|
169
206
|
deviceType: model.deviceType,
|
|
207
|
+
label: model.label ?? undefined,
|
|
208
|
+
createdDomain: model.createdDomain ?? undefined,
|
|
209
|
+
createdUserAgent: model.createdUserAgent ?? undefined,
|
|
210
|
+
createdBrowser: model.createdBrowser ?? undefined,
|
|
211
|
+
createdOs: model.createdOs ?? undefined,
|
|
212
|
+
createdDevice: model.createdDevice ?? undefined,
|
|
213
|
+
createdIp: model.createdIp ?? undefined,
|
|
170
214
|
createdAt: model.createdAt ?? undefined,
|
|
171
215
|
updatedAt: model.updatedAt ?? undefined
|
|
172
216
|
};
|
|
@@ -179,7 +223,14 @@ export class SequelizePasskeyStore extends PasskeyStore {
|
|
|
179
223
|
counter: record.counter,
|
|
180
224
|
transports: record.transports ?? null,
|
|
181
225
|
backedUp: record.backedUp,
|
|
182
|
-
deviceType: record.deviceType
|
|
226
|
+
deviceType: record.deviceType,
|
|
227
|
+
label: record.label ?? null,
|
|
228
|
+
createdDomain: record.createdDomain ?? null,
|
|
229
|
+
createdUserAgent: record.createdUserAgent ?? null,
|
|
230
|
+
createdBrowser: record.createdBrowser ?? null,
|
|
231
|
+
createdOs: record.createdOs ?? null,
|
|
232
|
+
createdDevice: record.createdDevice ?? null,
|
|
233
|
+
createdIp: record.createdIp ?? null
|
|
183
234
|
});
|
|
184
235
|
}
|
|
185
236
|
async updateCredentialCounter(credentialId, counter) {
|
|
@@ -191,7 +242,6 @@ export class SequelizePasskeyStore extends PasskeyStore {
|
|
|
191
242
|
action: record.action,
|
|
192
243
|
userId: record.userId !== undefined ? normalizeUserId(record.userId) : null,
|
|
193
244
|
login: record.login ?? null,
|
|
194
|
-
metadata: (record.metadata ?? {}),
|
|
195
245
|
expiresAt: record.expiresAt
|
|
196
246
|
});
|
|
197
247
|
}
|
|
@@ -206,8 +256,7 @@ export class SequelizePasskeyStore extends PasskeyStore {
|
|
|
206
256
|
action: model.action,
|
|
207
257
|
userId: model.userId ?? undefined,
|
|
208
258
|
login: model.login ?? undefined,
|
|
209
|
-
expiresAt: model.expiresAt
|
|
210
|
-
metadata: model.metadata ?? {}
|
|
259
|
+
expiresAt: model.expiresAt
|
|
211
260
|
};
|
|
212
261
|
}
|
|
213
262
|
async cleanupChallenges(now) {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { PasskeyChallenge, PasskeyChallengeParams, PasskeyStorageAdapter, PasskeyVerificationParams, PasskeyVerificationResult, PasskeyServiceConfig, StoredPasskeyCredential } from './types.js';
|
|
2
2
|
import type { AuthIdentifier } from '../auth-api/types.js';
|
|
3
|
-
export type { CredentialDeviceType, PasskeyChallenge, PasskeyChallengeParams, PasskeyChallengeRecord,
|
|
3
|
+
export type { CredentialDeviceType, PasskeyChallenge, PasskeyChallengeParams, PasskeyChallengeRecord, PasskeyUserDescriptor, PasskeyVerificationParams, PasskeyVerificationResult, PasskeyServiceConfig, PasskeyStorageAdapter, StoredPasskeyCredential } from './types.js';
|
|
4
4
|
type Logger = Pick<typeof console, 'error' | 'warn'>;
|
|
5
5
|
export declare class PasskeyService {
|
|
6
6
|
private readonly config;
|
|
@@ -18,6 +18,13 @@ function sanitizeTransports(input) {
|
|
|
18
18
|
.filter((value) => ALLOWED_TRANSPORTS.includes(value));
|
|
19
19
|
return filtered.length > 0 ? filtered : undefined;
|
|
20
20
|
}
|
|
21
|
+
function toOptionalString(value) {
|
|
22
|
+
if (typeof value !== 'string') {
|
|
23
|
+
return undefined;
|
|
24
|
+
}
|
|
25
|
+
const trimmed = value.trim();
|
|
26
|
+
return trimmed.length > 0 ? trimmed : undefined;
|
|
27
|
+
}
|
|
21
28
|
function toBase64Url(buffer) {
|
|
22
29
|
return isoBase64URL.fromBuffer(new Uint8Array(buffer));
|
|
23
30
|
}
|
|
@@ -91,17 +98,11 @@ export class PasskeyService {
|
|
|
91
98
|
}
|
|
92
99
|
async createChallenge(params) {
|
|
93
100
|
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
101
|
if (params.action === 'register') {
|
|
101
|
-
return this.createRegistrationChallenge(params
|
|
102
|
+
return this.createRegistrationChallenge(params);
|
|
102
103
|
}
|
|
103
104
|
if (params.action === 'authenticate') {
|
|
104
|
-
return this.createAuthenticationChallenge(params
|
|
105
|
+
return this.createAuthenticationChallenge(params);
|
|
105
106
|
}
|
|
106
107
|
throw new Error(`Unsupported passkey action: ${String(params.action)}`);
|
|
107
108
|
}
|
|
@@ -127,7 +128,7 @@ export class PasskeyService {
|
|
|
127
128
|
}
|
|
128
129
|
return { verified: false };
|
|
129
130
|
}
|
|
130
|
-
async createRegistrationChallenge(params
|
|
131
|
+
async createRegistrationChallenge(params) {
|
|
131
132
|
const user = await this.requireUser({ userId: params.userId, login: params.login });
|
|
132
133
|
const existing = await this.adapter.listUserCredentials(user.id);
|
|
133
134
|
const excludeCredentials = existing.map((credential) => {
|
|
@@ -150,8 +151,7 @@ export class PasskeyService {
|
|
|
150
151
|
action: 'register',
|
|
151
152
|
userId: user.id,
|
|
152
153
|
login: user.login,
|
|
153
|
-
expiresAt
|
|
154
|
-
metadata
|
|
154
|
+
expiresAt
|
|
155
155
|
});
|
|
156
156
|
return {
|
|
157
157
|
challenge: options.challenge,
|
|
@@ -160,7 +160,7 @@ export class PasskeyService {
|
|
|
160
160
|
publicKey: options
|
|
161
161
|
};
|
|
162
162
|
}
|
|
163
|
-
async createAuthenticationChallenge(params
|
|
163
|
+
async createAuthenticationChallenge(params) {
|
|
164
164
|
const user = await this.requireUser({ userId: params.userId, login: params.login });
|
|
165
165
|
const credentials = await this.adapter.listUserCredentials(user.id);
|
|
166
166
|
const allowCredentials = credentials.map((credential) => {
|
|
@@ -180,8 +180,7 @@ export class PasskeyService {
|
|
|
180
180
|
action: 'authenticate',
|
|
181
181
|
userId: user.id,
|
|
182
182
|
login: user.login,
|
|
183
|
-
expiresAt
|
|
184
|
-
metadata
|
|
183
|
+
expiresAt
|
|
185
184
|
});
|
|
186
185
|
return {
|
|
187
186
|
challenge: options.challenge,
|
|
@@ -259,7 +258,14 @@ export class PasskeyService {
|
|
|
259
258
|
counter: registrationInfo.counter ?? 0,
|
|
260
259
|
transports: sanitizeTransports(params.response.transports),
|
|
261
260
|
backedUp: registrationInfo.credentialDeviceType === 'multiDevice',
|
|
262
|
-
deviceType: registrationInfo.credentialDeviceType
|
|
261
|
+
deviceType: registrationInfo.credentialDeviceType,
|
|
262
|
+
label: toOptionalString(params.label),
|
|
263
|
+
createdDomain: toOptionalString(params.domain),
|
|
264
|
+
createdUserAgent: toOptionalString(params.userAgent),
|
|
265
|
+
createdBrowser: toOptionalString(params.browser),
|
|
266
|
+
createdOs: toOptionalString(params.os),
|
|
267
|
+
createdDevice: toOptionalString(params.device),
|
|
268
|
+
createdIp: toOptionalString(params.ip)
|
|
263
269
|
});
|
|
264
270
|
return { verified: true, userId: user.id, login: user.login };
|
|
265
271
|
}
|
|
@@ -9,19 +9,12 @@ export interface PasskeyServiceConfig {
|
|
|
9
9
|
timeoutMs: number;
|
|
10
10
|
userVerification?: 'preferred' | 'required' | 'discouraged';
|
|
11
11
|
}
|
|
12
|
-
export interface PasskeyChallengeMetadata {
|
|
13
|
-
domain?: string;
|
|
14
|
-
fingerprint?: string;
|
|
15
|
-
label?: string;
|
|
16
|
-
userAgent?: string;
|
|
17
|
-
}
|
|
18
12
|
export interface PasskeyChallengeRecord {
|
|
19
13
|
challenge: string;
|
|
20
14
|
action: 'register' | 'authenticate';
|
|
21
15
|
userId?: AuthIdentifier;
|
|
22
16
|
login?: string;
|
|
23
17
|
expiresAt: Date;
|
|
24
|
-
metadata: PasskeyChallengeMetadata;
|
|
25
18
|
}
|
|
26
19
|
export interface PasskeyUserDescriptor {
|
|
27
20
|
id: AuthIdentifier;
|
|
@@ -36,6 +29,13 @@ export interface StoredPasskeyCredential {
|
|
|
36
29
|
transports?: AuthenticatorTransportFuture[];
|
|
37
30
|
backedUp: boolean;
|
|
38
31
|
deviceType: CredentialDeviceType;
|
|
32
|
+
label?: string;
|
|
33
|
+
createdDomain?: string;
|
|
34
|
+
createdUserAgent?: string;
|
|
35
|
+
createdBrowser?: string;
|
|
36
|
+
createdOs?: string;
|
|
37
|
+
createdDevice?: string;
|
|
38
|
+
createdIp?: string;
|
|
39
39
|
createdAt?: Date;
|
|
40
40
|
updatedAt?: Date;
|
|
41
41
|
}
|
|
@@ -53,10 +53,9 @@ export interface PasskeyStorageAdapter {
|
|
|
53
53
|
consumeChallenge(challenge: string): Promise<PasskeyChallengeRecord | null>;
|
|
54
54
|
cleanupChallenges?(now: Date): Promise<void>;
|
|
55
55
|
}
|
|
56
|
-
export interface PasskeyChallengeParams
|
|
56
|
+
export interface PasskeyChallengeParams {
|
|
57
57
|
action: 'register' | 'authenticate';
|
|
58
58
|
login?: string;
|
|
59
|
-
userAgent?: string;
|
|
60
59
|
userId?: AuthIdentifier;
|
|
61
60
|
}
|
|
62
61
|
export interface PasskeyChallenge extends Record<string, unknown> {
|
|
@@ -69,6 +68,7 @@ export interface PasskeyVerificationParams extends Partial<Omit<Token, 'userId'>
|
|
|
69
68
|
login?: string;
|
|
70
69
|
response: Record<string, unknown>;
|
|
71
70
|
userId?: AuthIdentifier;
|
|
71
|
+
userAgent?: string;
|
|
72
72
|
}
|
|
73
73
|
export interface PasskeyVerificationResult extends Record<string, unknown> {
|
|
74
74
|
login?: string;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@technomoron/api-server-base",
|
|
3
|
-
"version": "2.0.0-beta.
|
|
3
|
+
"version": "2.0.0-beta.12",
|
|
4
4
|
"description": "Api Server Skeleton / Base Class",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/cjs/index.cjs",
|
|
@@ -42,18 +42,18 @@
|
|
|
42
42
|
},
|
|
43
43
|
"dependencies": {
|
|
44
44
|
"@simplewebauthn/server": "^13.2.2",
|
|
45
|
-
"@types/cookie-parser": "^1.4.
|
|
45
|
+
"@types/cookie-parser": "^1.4.10",
|
|
46
46
|
"@types/cors": "^2.8.19",
|
|
47
|
-
"@types/express": "^4.17.
|
|
47
|
+
"@types/express": "^4.17.25",
|
|
48
48
|
"@types/jsonwebtoken": "^9.0.10",
|
|
49
49
|
"@types/multer": "^1.4.13",
|
|
50
50
|
"bcryptjs": "^2.4.3",
|
|
51
51
|
"cookie-parser": "^1.4.7",
|
|
52
52
|
"cors": "^2.8.5",
|
|
53
|
-
"express": "^4.
|
|
54
|
-
"jsonwebtoken": "^9.0.
|
|
53
|
+
"express": "^4.22.1",
|
|
54
|
+
"jsonwebtoken": "^9.0.3",
|
|
55
55
|
"multer": "^2.0.2",
|
|
56
|
-
"mysql2": "^3.
|
|
56
|
+
"mysql2": "^3.16.0",
|
|
57
57
|
"pg": "^8.16.3",
|
|
58
58
|
"sequelize": "^6.37.7",
|
|
59
59
|
"sqlite3": "^5.1.7"
|
|
@@ -62,14 +62,14 @@
|
|
|
62
62
|
"@types/bcryptjs": "^2.4.6",
|
|
63
63
|
"@types/express-serve-static-core": "^5.1.0",
|
|
64
64
|
"@types/supertest": "^6.0.3",
|
|
65
|
-
"@typescript-eslint/eslint-plugin": "^8.
|
|
66
|
-
"@typescript-eslint/parser": "^8.
|
|
65
|
+
"@typescript-eslint/eslint-plugin": "^8.50.0",
|
|
66
|
+
"@typescript-eslint/parser": "^8.50.0",
|
|
67
67
|
"@vue/eslint-config-prettier": "10.2.0",
|
|
68
68
|
"@vue/eslint-config-typescript": "14.5.0",
|
|
69
|
-
"eslint": "^9.
|
|
69
|
+
"eslint": "^9.39.2",
|
|
70
70
|
"eslint-plugin-import": "^2.32.0",
|
|
71
|
-
"eslint-plugin-vue": "^10.
|
|
72
|
-
"prettier": "^3.
|
|
71
|
+
"eslint-plugin-vue": "^10.6.2",
|
|
72
|
+
"prettier": "^3.7.4",
|
|
73
73
|
"supertest": "^7.1.4",
|
|
74
74
|
"typescript": "^5.9.3",
|
|
75
75
|
"vitest": "^3.2.4"
|