@technomoron/api-server-base 2.0.0-beta.2 → 2.0.0-beta.20
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.txt +81 -28
- package/dist/cjs/api-module.cjs +9 -0
- package/dist/cjs/api-module.d.ts +7 -4
- package/dist/cjs/api-server-base.cjs +607 -99
- package/dist/cjs/api-server-base.d.ts +80 -23
- package/dist/cjs/auth-api/auth-module.d.ts +23 -3
- package/dist/cjs/auth-api/auth-module.js +320 -124
- package/dist/cjs/auth-api/compat-auth-storage.d.ts +7 -5
- package/dist/cjs/auth-api/compat-auth-storage.js +15 -3
- package/dist/cjs/auth-api/mem-auth-store.d.ts +5 -3
- package/dist/cjs/auth-api/mem-auth-store.js +14 -28
- package/dist/cjs/auth-api/module.d.ts +1 -1
- package/dist/cjs/auth-api/sql-auth-store.d.ts +16 -4
- package/dist/cjs/auth-api/sql-auth-store.js +43 -30
- package/dist/cjs/auth-api/storage.d.ts +6 -4
- package/dist/cjs/auth-api/storage.js +15 -5
- package/dist/cjs/auth-api/types.d.ts +7 -2
- package/dist/cjs/auth-api/user-id.d.ts +5 -0
- package/dist/cjs/auth-api/user-id.js +38 -0
- package/dist/cjs/auth-cookie-options.d.ts +11 -0
- package/dist/cjs/auth-cookie-options.js +66 -0
- package/dist/cjs/index.cjs +4 -14
- package/dist/cjs/index.d.ts +4 -9
- package/dist/cjs/oauth/memory.d.ts +6 -0
- package/dist/cjs/oauth/memory.js +44 -11
- package/dist/cjs/oauth/models.d.ts +7 -2
- package/dist/cjs/oauth/models.js +10 -21
- package/dist/cjs/oauth/sequelize.d.ts +10 -48
- package/dist/cjs/oauth/sequelize.js +44 -99
- package/dist/cjs/oauth/types.d.ts +1 -0
- package/dist/cjs/passkey/base.d.ts +2 -0
- package/dist/cjs/passkey/config.d.ts +2 -0
- package/dist/cjs/passkey/config.js +26 -0
- package/dist/cjs/passkey/memory.d.ts +8 -0
- package/dist/cjs/passkey/memory.js +57 -16
- package/dist/cjs/passkey/models.d.ts +13 -4
- package/dist/cjs/passkey/models.js +41 -14
- package/dist/cjs/passkey/sequelize.d.ts +13 -25
- package/dist/cjs/passkey/sequelize.js +68 -153
- package/dist/cjs/passkey/service.d.ts +6 -2
- package/dist/cjs/passkey/service.js +205 -27
- package/dist/cjs/passkey/types.d.ts +18 -9
- package/dist/cjs/sequelize-utils.d.ts +8 -0
- package/dist/cjs/sequelize-utils.js +57 -0
- package/dist/cjs/token/base.d.ts +2 -1
- package/dist/cjs/token/base.js +3 -1
- package/dist/cjs/token/memory.d.ts +10 -0
- package/dist/cjs/token/memory.js +122 -32
- package/dist/cjs/token/sequelize.d.ts +4 -4
- package/dist/cjs/token/sequelize.js +67 -85
- package/dist/cjs/token/types.d.ts +8 -1
- package/dist/cjs/user/base.d.ts +1 -0
- package/dist/cjs/user/base.js +11 -4
- package/dist/cjs/user/memory.d.ts +2 -0
- package/dist/cjs/user/memory.js +9 -10
- package/dist/cjs/user/sequelize.d.ts +7 -2
- package/dist/cjs/user/sequelize.js +19 -32
- package/dist/esm/api-module.d.ts +7 -4
- package/dist/esm/api-module.js +9 -0
- package/dist/esm/api-server-base.d.ts +80 -23
- package/dist/esm/api-server-base.js +608 -100
- package/dist/esm/auth-api/auth-module.d.ts +23 -3
- package/dist/esm/auth-api/auth-module.js +321 -125
- package/dist/esm/auth-api/compat-auth-storage.d.ts +7 -5
- package/dist/esm/auth-api/compat-auth-storage.js +13 -1
- package/dist/esm/auth-api/mem-auth-store.d.ts +5 -3
- package/dist/esm/auth-api/mem-auth-store.js +14 -28
- package/dist/esm/auth-api/module.d.ts +1 -1
- package/dist/esm/auth-api/sql-auth-store.d.ts +16 -4
- package/dist/esm/auth-api/sql-auth-store.js +43 -30
- package/dist/esm/auth-api/storage.d.ts +6 -4
- package/dist/esm/auth-api/storage.js +13 -3
- package/dist/esm/auth-api/types.d.ts +7 -2
- package/dist/esm/auth-api/user-id.d.ts +5 -0
- package/dist/esm/auth-api/user-id.js +32 -0
- package/dist/esm/auth-cookie-options.d.ts +11 -0
- package/dist/esm/auth-cookie-options.js +63 -0
- package/dist/esm/index.d.ts +4 -9
- package/dist/esm/index.js +2 -7
- package/dist/esm/oauth/memory.d.ts +6 -0
- package/dist/esm/oauth/memory.js +44 -11
- package/dist/esm/oauth/models.d.ts +7 -2
- package/dist/esm/oauth/models.js +6 -19
- package/dist/esm/oauth/sequelize.d.ts +10 -48
- package/dist/esm/oauth/sequelize.js +32 -87
- package/dist/esm/oauth/types.d.ts +1 -0
- package/dist/esm/passkey/base.d.ts +2 -0
- package/dist/esm/passkey/config.d.ts +2 -0
- package/dist/esm/passkey/config.js +23 -0
- package/dist/esm/passkey/memory.d.ts +8 -0
- package/dist/esm/passkey/memory.js +57 -16
- package/dist/esm/passkey/models.d.ts +13 -4
- package/dist/esm/passkey/models.js +39 -12
- package/dist/esm/passkey/sequelize.d.ts +13 -25
- package/dist/esm/passkey/sequelize.js +69 -154
- package/dist/esm/passkey/service.d.ts +6 -2
- package/dist/esm/passkey/service.js +173 -28
- package/dist/esm/passkey/types.d.ts +18 -9
- package/dist/esm/sequelize-utils.d.ts +8 -0
- package/dist/esm/sequelize-utils.js +48 -0
- package/dist/esm/token/base.d.ts +2 -1
- package/dist/esm/token/base.js +3 -1
- package/dist/esm/token/memory.d.ts +10 -0
- package/dist/esm/token/memory.js +122 -32
- package/dist/esm/token/sequelize.d.ts +4 -4
- package/dist/esm/token/sequelize.js +67 -85
- package/dist/esm/token/types.d.ts +8 -1
- package/dist/esm/user/base.d.ts +1 -0
- package/dist/esm/user/base.js +11 -4
- package/dist/esm/user/memory.d.ts +2 -0
- package/dist/esm/user/memory.js +9 -10
- package/dist/esm/user/sequelize.d.ts +7 -2
- package/dist/esm/user/sequelize.js +19 -32
- package/docs/swagger/openapi.json +1876 -0
- package/package.json +81 -32
|
@@ -1,6 +1,9 @@
|
|
|
1
|
-
import { createHash } from 'node:crypto';
|
|
1
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
2
|
+
import { isoBase64URL } from '@simplewebauthn/server/helpers';
|
|
2
3
|
import { ApiError } from '../api-server-base.js';
|
|
4
|
+
import { buildAuthCookieOptions } from '../auth-cookie-options.js';
|
|
3
5
|
import { BaseAuthModule } from './module.js';
|
|
6
|
+
import { BaseAuthAdapter } from './storage.js';
|
|
4
7
|
function isAuthIdentifier(value) {
|
|
5
8
|
return typeof value === 'string' || typeof value === 'number';
|
|
6
9
|
}
|
|
@@ -28,10 +31,18 @@ function sha256Base64Url(value) {
|
|
|
28
31
|
return base64UrlEncode(hash);
|
|
29
32
|
}
|
|
30
33
|
class AuthModule extends BaseAuthModule {
|
|
34
|
+
get server() {
|
|
35
|
+
return super.server;
|
|
36
|
+
}
|
|
37
|
+
set server(value) {
|
|
38
|
+
super.server = value;
|
|
39
|
+
}
|
|
31
40
|
constructor(options = {}) {
|
|
32
41
|
super({ namespace: options.namespace ?? AuthModule.defaultNamespace });
|
|
33
42
|
this.defaultDomain = options.defaultDomain;
|
|
34
43
|
this.canImpersonateHook = options.canImpersonate;
|
|
44
|
+
this.rateLimitHook = options.rateLimit;
|
|
45
|
+
this.allowInsecurePkcePlain = options.allowInsecurePkcePlain ?? true;
|
|
35
46
|
}
|
|
36
47
|
get storage() {
|
|
37
48
|
return this.server.getAuthStorage();
|
|
@@ -72,9 +83,21 @@ class AuthModule extends BaseAuthModule {
|
|
|
72
83
|
}
|
|
73
84
|
buildTokenMetadata(metadata = {}) {
|
|
74
85
|
const scope = metadata.scope;
|
|
86
|
+
const domain = metadata.domain ?? this.defaultDomain ?? '';
|
|
87
|
+
let fingerprint = metadata.fingerprint ?? metadata.clientId ?? '';
|
|
88
|
+
if (typeof fingerprint === 'string') {
|
|
89
|
+
fingerprint = fingerprint.trim();
|
|
90
|
+
}
|
|
91
|
+
else {
|
|
92
|
+
fingerprint = '';
|
|
93
|
+
}
|
|
94
|
+
// Avoid every client sharing the empty-string fingerprint which collapses sessions into one bucket.
|
|
95
|
+
if (!fingerprint) {
|
|
96
|
+
fingerprint = `srv-${randomUUID()}`;
|
|
97
|
+
}
|
|
75
98
|
return {
|
|
76
|
-
domain
|
|
77
|
-
fingerprint
|
|
99
|
+
domain,
|
|
100
|
+
fingerprint,
|
|
78
101
|
label: metadata.label ?? (Array.isArray(scope) ? scope.join(' ') : typeof scope === 'string' ? scope : ''),
|
|
79
102
|
clientId: metadata.clientId,
|
|
80
103
|
ruid: metadata.ruid,
|
|
@@ -132,6 +155,9 @@ class AuthModule extends BaseAuthModule {
|
|
|
132
155
|
return candidate ? {} : { sessionCookie: true, refreshTtlSeconds: this.sessionRefreshTtlSeconds() };
|
|
133
156
|
}
|
|
134
157
|
if (typeof candidate === 'number') {
|
|
158
|
+
if (candidate === 0) {
|
|
159
|
+
return { sessionCookie: true, refreshTtlSeconds: this.sessionRefreshTtlSeconds() };
|
|
160
|
+
}
|
|
135
161
|
const ttl = this.normalizeRefreshTtlSeconds(candidate);
|
|
136
162
|
return ttl ? { sessionCookie: false, refreshTtlSeconds: ttl } : {};
|
|
137
163
|
}
|
|
@@ -181,30 +207,46 @@ class AuthModule extends BaseAuthModule {
|
|
|
181
207
|
}
|
|
182
208
|
return prefs;
|
|
183
209
|
}
|
|
184
|
-
|
|
185
|
-
const
|
|
186
|
-
const
|
|
187
|
-
const
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
210
|
+
validateCredentialId(apiReq) {
|
|
211
|
+
const paramId = toStringOrNull(apiReq.req.params?.credentialId);
|
|
212
|
+
const bodyId = toStringOrNull(apiReq.req.body?.credentialId);
|
|
213
|
+
const credentialId = paramId ?? bodyId;
|
|
214
|
+
if (!credentialId) {
|
|
215
|
+
throw new ApiError({ code: 400, message: 'credentialId is required' });
|
|
216
|
+
}
|
|
217
|
+
try {
|
|
218
|
+
isoBase64URL.toBuffer(credentialId);
|
|
219
|
+
}
|
|
220
|
+
catch {
|
|
221
|
+
throw new ApiError({ code: 400, message: 'Invalid credentialId' });
|
|
222
|
+
}
|
|
223
|
+
return credentialId;
|
|
224
|
+
}
|
|
225
|
+
normalizeCredentialId(value) {
|
|
226
|
+
if (Buffer.isBuffer(value)) {
|
|
227
|
+
return value;
|
|
228
|
+
}
|
|
229
|
+
try {
|
|
230
|
+
return Buffer.from(isoBase64URL.toBuffer(value));
|
|
231
|
+
}
|
|
232
|
+
catch {
|
|
233
|
+
try {
|
|
234
|
+
return Buffer.from(value, 'base64');
|
|
235
|
+
}
|
|
236
|
+
catch {
|
|
237
|
+
return Buffer.from(value);
|
|
205
238
|
}
|
|
206
239
|
}
|
|
207
|
-
|
|
240
|
+
}
|
|
241
|
+
toIsoDate(value) {
|
|
242
|
+
if (!value) {
|
|
243
|
+
return undefined;
|
|
244
|
+
}
|
|
245
|
+
const date = value instanceof Date ? value : new Date(value);
|
|
246
|
+
return Number.isNaN(date.getTime()) ? undefined : date.toISOString();
|
|
247
|
+
}
|
|
248
|
+
cookieOptions(apiReq) {
|
|
249
|
+
return buildAuthCookieOptions(this.server.config, apiReq.req);
|
|
208
250
|
}
|
|
209
251
|
setJwtCookies(apiReq, tokens, preferences = {}) {
|
|
210
252
|
const conf = this.server.config;
|
|
@@ -231,7 +273,10 @@ class AuthModule extends BaseAuthModule {
|
|
|
231
273
|
async issueTokens(apiReq, user, metadata = {}) {
|
|
232
274
|
const conf = this.server.config;
|
|
233
275
|
const enrichedMetadata = this.enrichTokenMetadata(apiReq, metadata);
|
|
234
|
-
const payload =
|
|
276
|
+
const payload = {
|
|
277
|
+
...this.buildTokenPayload(user, enrichedMetadata),
|
|
278
|
+
jti: randomUUID()
|
|
279
|
+
};
|
|
235
280
|
const access = this.server.jwtSign(payload, conf.accessSecret, conf.accessExpiry);
|
|
236
281
|
if (!access.success || !access.token) {
|
|
237
282
|
throw new ApiError({ code: 500, message: access.error ?? 'Unable to sign access token' });
|
|
@@ -401,23 +446,16 @@ class AuthModule extends BaseAuthModule {
|
|
|
401
446
|
return undefined;
|
|
402
447
|
}
|
|
403
448
|
async postLogin(apiReq) {
|
|
449
|
+
await this.applyRateLimit(apiReq, 'login');
|
|
404
450
|
this.assertAuthReady();
|
|
405
451
|
const { login, password, ...metadata } = this.parseLoginBody(apiReq);
|
|
406
452
|
const user = await this.storage.getUser(login);
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
message: 'Invalid credentials',
|
|
411
|
-
errors: { login: 'Unknown user' }
|
|
412
|
-
});
|
|
413
|
-
}
|
|
414
|
-
const hash = this.storage.getUserPasswordHash(user);
|
|
415
|
-
const verified = await this.storage.verifyPassword(password, hash);
|
|
416
|
-
if (!verified) {
|
|
453
|
+
const hash = user ? this.storage.getUserPasswordHash(user) : '';
|
|
454
|
+
const verified = user ? await this.storage.verifyPassword(password, hash) : false;
|
|
455
|
+
if (!user || !verified) {
|
|
417
456
|
throw new ApiError({
|
|
418
457
|
code: 400,
|
|
419
|
-
message: 'Invalid credentials'
|
|
420
|
-
errors: { password: 'Wrong password' }
|
|
458
|
+
message: 'Invalid credentials'
|
|
421
459
|
});
|
|
422
460
|
}
|
|
423
461
|
const pair = await this.issueTokens(apiReq, user, metadata);
|
|
@@ -459,6 +497,7 @@ class AuthModule extends BaseAuthModule {
|
|
|
459
497
|
refreshTtlSeconds: sessionPrefs.refreshTtlSeconds ?? stored.refreshTtlSeconds,
|
|
460
498
|
sessionCookie: sessionPrefs.sessionCookie ?? stored.sessionCookie
|
|
461
499
|
};
|
|
500
|
+
await this.storage.deleteToken({ refreshToken: providedToken });
|
|
462
501
|
const pair = await this.issueTokens(apiReq, user, metadata);
|
|
463
502
|
const publicUser = this.storage.filterUser(user);
|
|
464
503
|
return [200, { ...pair, user: publicUser }];
|
|
@@ -498,10 +537,39 @@ class AuthModule extends BaseAuthModule {
|
|
|
498
537
|
apiReq.req.cookies[conf.accessCookie].trim().length > 0);
|
|
499
538
|
const shouldRefresh = Boolean(body.refresh) || !hasAccessToken;
|
|
500
539
|
if (shouldRefresh) {
|
|
501
|
-
const
|
|
540
|
+
const updateToken = this.storage.updateToken;
|
|
541
|
+
if (typeof updateToken !== 'function' || !this.storageImplements('updateToken')) {
|
|
542
|
+
throw new ApiError({ code: 501, message: 'Token update storage is not configured' });
|
|
543
|
+
}
|
|
544
|
+
// Sign a new access token without embedding stored token secrets into the JWT payload.
|
|
545
|
+
const metadata = {
|
|
546
|
+
ruid: stored.ruid,
|
|
547
|
+
domain: stored.domain,
|
|
548
|
+
fingerprint: stored.fingerprint,
|
|
549
|
+
label: stored.label,
|
|
550
|
+
clientId: stored.clientId,
|
|
551
|
+
scope: stored.scope,
|
|
552
|
+
browser: stored.browser,
|
|
553
|
+
device: stored.device,
|
|
554
|
+
ip: stored.ip,
|
|
555
|
+
os: stored.os,
|
|
556
|
+
loginType: stored.loginType,
|
|
557
|
+
refreshTtlSeconds: this.normalizeRefreshTtlSeconds(stored.refreshTtlSeconds),
|
|
558
|
+
sessionCookie: stored.sessionCookie
|
|
559
|
+
};
|
|
560
|
+
const enrichedMetadata = this.enrichTokenMetadata(apiReq, metadata);
|
|
561
|
+
const access = this.server.jwtSign(this.buildTokenPayload(user, enrichedMetadata), conf.accessSecret, conf.accessExpiry);
|
|
502
562
|
if (!access.success || !access.token) {
|
|
503
563
|
throw new ApiError({ code: 500, message: access.error ?? 'Unable to sign access token' });
|
|
504
564
|
}
|
|
565
|
+
const updated = await updateToken.call(this.storage, {
|
|
566
|
+
refreshToken,
|
|
567
|
+
accessToken: access.token,
|
|
568
|
+
lastSeenAt: new Date()
|
|
569
|
+
});
|
|
570
|
+
if (!updated) {
|
|
571
|
+
throw new ApiError({ code: 500, message: 'Unable to persist refreshed access token' });
|
|
572
|
+
}
|
|
505
573
|
const cookiePrefs = this.mergeSessionPreferences({
|
|
506
574
|
sessionCookie: stored.sessionCookie,
|
|
507
575
|
refreshTtlSeconds: this.normalizeRefreshTtlSeconds(stored.refreshTtlSeconds)
|
|
@@ -511,9 +579,31 @@ class AuthModule extends BaseAuthModule {
|
|
|
511
579
|
: cookiePrefs.refreshTtlSeconds;
|
|
512
580
|
this.setJwtCookies(apiReq, { accessToken: access.token, refreshToken }, { sessionCookie: cookiePrefs.sessionCookie ?? false, refreshTtlSeconds: refreshTtlForCookie });
|
|
513
581
|
}
|
|
514
|
-
|
|
582
|
+
const tokenClaims = verify.data;
|
|
583
|
+
const effectiveUserId = this.storage.getUserId(user);
|
|
584
|
+
const effectiveId = String(effectiveUserId);
|
|
585
|
+
const rawRealId = stored.ruid ?? tokenClaims.ruid;
|
|
586
|
+
const normalizedRealId = rawRealId === undefined || rawRealId === null ? null : String(rawRealId).trim() || null;
|
|
587
|
+
const isImpersonating = normalizedRealId !== null && normalizedRealId !== effectiveId;
|
|
588
|
+
let realUser;
|
|
589
|
+
let realUserId;
|
|
590
|
+
if (isImpersonating && normalizedRealId !== null) {
|
|
591
|
+
const realUserEntity = await this.getUserOrThrow(normalizedRealId, 'Real user not found');
|
|
592
|
+
realUser = this.storage.filterUser(realUserEntity);
|
|
593
|
+
realUserId = this.storage.getUserId(realUserEntity);
|
|
594
|
+
}
|
|
595
|
+
return [
|
|
596
|
+
200,
|
|
597
|
+
{
|
|
598
|
+
user: this.storage.filterUser(user),
|
|
599
|
+
isImpersonating,
|
|
600
|
+
realUser,
|
|
601
|
+
realUserId
|
|
602
|
+
}
|
|
603
|
+
];
|
|
515
604
|
}
|
|
516
605
|
async postPasskeyChallenge(apiReq) {
|
|
606
|
+
await this.applyRateLimit(apiReq, 'passkey-challenge');
|
|
517
607
|
if (typeof this.storage.createPasskeyChallenge !== 'function') {
|
|
518
608
|
throw new ApiError({ code: 501, message: 'Passkey support is not configured' });
|
|
519
609
|
}
|
|
@@ -525,15 +615,7 @@ class AuthModule extends BaseAuthModule {
|
|
|
525
615
|
const params = {
|
|
526
616
|
action,
|
|
527
617
|
login: toStringOrNull(body.login) ?? undefined,
|
|
528
|
-
userId: isAuthIdentifier(body.userId) ? body.userId : undefined
|
|
529
|
-
userAgent: toStringOrNull(body.userAgent) ?? undefined,
|
|
530
|
-
domain: toStringOrNull(body.domain) ?? undefined,
|
|
531
|
-
fingerprint: toStringOrNull(body.fingerprint) ?? undefined,
|
|
532
|
-
label: toStringOrNull(body.label) ?? undefined,
|
|
533
|
-
browser: toStringOrNull(body.browser) ?? undefined,
|
|
534
|
-
device: toStringOrNull(body.device) ?? undefined,
|
|
535
|
-
ip: toStringOrNull(body.ip) ?? undefined,
|
|
536
|
-
os: toStringOrNull(body.os) ?? undefined
|
|
618
|
+
userId: isAuthIdentifier(body.userId) ? body.userId : undefined
|
|
537
619
|
};
|
|
538
620
|
const challenge = await this.storage.createPasskeyChallenge(params);
|
|
539
621
|
return [200, challenge];
|
|
@@ -549,18 +631,25 @@ class AuthModule extends BaseAuthModule {
|
|
|
549
631
|
if (!expectedChallenge || typeof response !== 'object' || response === null) {
|
|
550
632
|
throw new ApiError({ code: 400, message: 'Malformed passkey verification payload' });
|
|
551
633
|
}
|
|
552
|
-
const
|
|
553
|
-
expectedChallenge,
|
|
554
|
-
response: response,
|
|
555
|
-
login: toStringOrNull(body.login) ?? undefined,
|
|
556
|
-
userId: isAuthIdentifier(body.userId) ? body.userId : undefined,
|
|
634
|
+
const rawMetadata = {
|
|
557
635
|
domain: toStringOrNull(body.domain) ?? undefined,
|
|
558
636
|
fingerprint: toStringOrNull(body.fingerprint) ?? undefined,
|
|
559
637
|
label: toStringOrNull(body.label) ?? undefined,
|
|
560
638
|
browser: toStringOrNull(body.browser) ?? undefined,
|
|
561
639
|
device: toStringOrNull(body.device) ?? undefined,
|
|
562
640
|
ip: toStringOrNull(body.ip) ?? undefined,
|
|
563
|
-
os: toStringOrNull(body.os) ?? undefined
|
|
641
|
+
os: toStringOrNull(body.os) ?? undefined
|
|
642
|
+
};
|
|
643
|
+
const clientInfo = apiReq.getClientInfo();
|
|
644
|
+
const userAgent = toStringOrNull(body.userAgent) ?? (clientInfo.ua ? clientInfo.ua : null);
|
|
645
|
+
const requestMetadata = this.enrichTokenMetadata(apiReq, rawMetadata);
|
|
646
|
+
const params = {
|
|
647
|
+
expectedChallenge,
|
|
648
|
+
response: response,
|
|
649
|
+
login: toStringOrNull(body.login) ?? undefined,
|
|
650
|
+
userId: isAuthIdentifier(body.userId) ? body.userId : undefined,
|
|
651
|
+
userAgent: userAgent ?? undefined,
|
|
652
|
+
...requestMetadata,
|
|
564
653
|
...sessionPrefs
|
|
565
654
|
};
|
|
566
655
|
const result = await this.storage.verifyPasskeyResponse(params);
|
|
@@ -596,6 +685,44 @@ class AuthModule extends BaseAuthModule {
|
|
|
596
685
|
const publicUser = this.storage.filterUser(user);
|
|
597
686
|
return [200, { ...tokens, user: publicUser }];
|
|
598
687
|
}
|
|
688
|
+
async getPasskeys(apiReq) {
|
|
689
|
+
if (typeof this.storage.listUserCredentials !== 'function') {
|
|
690
|
+
throw new ApiError({ code: 501, message: 'Passkey credential listing is not configured' });
|
|
691
|
+
}
|
|
692
|
+
const { userId } = await this.resolveActorContext(apiReq);
|
|
693
|
+
const credentials = await this.storage.listUserCredentials(userId);
|
|
694
|
+
const safeCredentials = credentials.map((credential) => {
|
|
695
|
+
const bufferId = this.normalizeCredentialId(credential.credentialId);
|
|
696
|
+
return {
|
|
697
|
+
id: isoBase64URL.fromBuffer(new Uint8Array(bufferId)),
|
|
698
|
+
transports: credential.transports,
|
|
699
|
+
backedUp: credential.backedUp,
|
|
700
|
+
deviceType: credential.deviceType,
|
|
701
|
+
createdAt: this.toIsoDate(credential.createdAt),
|
|
702
|
+
updatedAt: this.toIsoDate(credential.updatedAt)
|
|
703
|
+
};
|
|
704
|
+
});
|
|
705
|
+
return [200, { credentials: safeCredentials }];
|
|
706
|
+
}
|
|
707
|
+
async deletePasskey(apiReq) {
|
|
708
|
+
if (typeof this.storage.listUserCredentials !== 'function' ||
|
|
709
|
+
typeof this.storage.deletePasskeyCredential !== 'function') {
|
|
710
|
+
throw new ApiError({ code: 501, message: 'Passkey credential management is not configured' });
|
|
711
|
+
}
|
|
712
|
+
const { userId } = await this.resolveActorContext(apiReq);
|
|
713
|
+
const credentialId = this.validateCredentialId(apiReq);
|
|
714
|
+
const bufferId = Buffer.from(isoBase64URL.toBuffer(credentialId));
|
|
715
|
+
const credentials = await this.storage.listUserCredentials(userId);
|
|
716
|
+
const owns = credentials.some((credential) => {
|
|
717
|
+
const candidateId = this.normalizeCredentialId(credential.credentialId);
|
|
718
|
+
return isoBase64URL.fromBuffer(new Uint8Array(candidateId)) === credentialId;
|
|
719
|
+
});
|
|
720
|
+
if (!owns) {
|
|
721
|
+
throw new ApiError({ code: 404, message: 'Passkey not found' });
|
|
722
|
+
}
|
|
723
|
+
const deleted = await this.storage.deletePasskeyCredential(bufferId);
|
|
724
|
+
return [200, { deleted }];
|
|
725
|
+
}
|
|
599
726
|
async postImpersonation(apiReq) {
|
|
600
727
|
this.assertAuthReady();
|
|
601
728
|
const { targetIdentifier, metadata } = this.parseImpersonationRequest(apiReq);
|
|
@@ -614,7 +741,8 @@ class AuthModule extends BaseAuthModule {
|
|
|
614
741
|
async deleteImpersonation(apiReq) {
|
|
615
742
|
this.assertAuthReady();
|
|
616
743
|
const actor = await this.resolveActorContext(apiReq);
|
|
617
|
-
const
|
|
744
|
+
const query = (apiReq.req.query ?? {});
|
|
745
|
+
const metadata = this.buildImpersonationMetadata(query);
|
|
618
746
|
metadata.loginType = metadata.loginType ?? 'impersonation-end';
|
|
619
747
|
const tokens = await this.issueTokens(apiReq, actor.user, metadata);
|
|
620
748
|
const publicUser = this.storage.filterUser(actor.user);
|
|
@@ -686,6 +814,7 @@ class AuthModule extends BaseAuthModule {
|
|
|
686
814
|
const state = toStringOrNull(body.state) ?? undefined;
|
|
687
815
|
const codeChallenge = toStringOrNull(body.codeChallenge) ?? undefined;
|
|
688
816
|
const codeChallengeMethod = toStringOrNull(body.codeChallengeMethod) ?? undefined;
|
|
817
|
+
const resolvedCodeChallengeMethod = this.resolvePkceChallengeMethod(codeChallengeMethod);
|
|
689
818
|
if (!clientId) {
|
|
690
819
|
throw new ApiError({ code: 400, message: 'clientId is required' });
|
|
691
820
|
}
|
|
@@ -705,7 +834,7 @@ class AuthModule extends BaseAuthModule {
|
|
|
705
834
|
redirectUri,
|
|
706
835
|
scope: resolvedScope,
|
|
707
836
|
codeChallenge,
|
|
708
|
-
codeChallengeMethod:
|
|
837
|
+
codeChallengeMethod: resolvedCodeChallengeMethod,
|
|
709
838
|
expiresInSeconds: 300
|
|
710
839
|
});
|
|
711
840
|
const redirect = new URL(redirectUri);
|
|
@@ -716,6 +845,7 @@ class AuthModule extends BaseAuthModule {
|
|
|
716
845
|
return [200, { code: codeRecord.code, redirectUri: redirect.toString(), state }];
|
|
717
846
|
}
|
|
718
847
|
async postOAuthToken(apiReq) {
|
|
848
|
+
await this.applyRateLimit(apiReq, 'oauth-token');
|
|
719
849
|
if (typeof this.storage.getClient !== 'function' || typeof this.storage.consumeAuthCode !== 'function') {
|
|
720
850
|
throw new ApiError({ code: 501, message: 'OAuth token storage is not configured' });
|
|
721
851
|
}
|
|
@@ -769,12 +899,15 @@ class AuthModule extends BaseAuthModule {
|
|
|
769
899
|
}
|
|
770
900
|
}
|
|
771
901
|
else if (record.codeChallengeMethod === 'plain') {
|
|
902
|
+
if (!this.allowInsecurePkcePlain) {
|
|
903
|
+
throw new ApiError({ code: 400, message: 'PKCE plain is not permitted' });
|
|
904
|
+
}
|
|
772
905
|
if (codeVerifier !== record.codeChallenge) {
|
|
773
906
|
throw new ApiError({ code: 400, message: 'code_verifier does not match challenge' });
|
|
774
907
|
}
|
|
775
908
|
}
|
|
776
909
|
}
|
|
777
|
-
else if (!clientSecretProvided && client.clientSecret) {
|
|
910
|
+
else if (!clientSecretProvided && (client.hasSecret ?? Boolean(client.clientSecret))) {
|
|
778
911
|
throw new ApiError({ code: 400, message: 'Client authentication required when no PKCE challenge present' });
|
|
779
912
|
}
|
|
780
913
|
const user = await this.getUserOrThrow(record.userId, 'User not found');
|
|
@@ -809,6 +942,7 @@ class AuthModule extends BaseAuthModule {
|
|
|
809
942
|
throw new ApiError({ code: 400, message: 'Refresh token issued to another client' });
|
|
810
943
|
}
|
|
811
944
|
const user = await this.getUserOrThrow(stored.userId ?? verify.data.uid, 'User not found');
|
|
945
|
+
await this.storage.deleteToken({ refreshToken });
|
|
812
946
|
const tokens = await this.issueTokens(apiReq, user, {
|
|
813
947
|
clientId: client.clientId,
|
|
814
948
|
scope: stored.scope,
|
|
@@ -817,11 +951,7 @@ class AuthModule extends BaseAuthModule {
|
|
|
817
951
|
loginType: stored.loginType ?? 'oauth'
|
|
818
952
|
});
|
|
819
953
|
this.clearOAuthCookies(apiReq);
|
|
820
|
-
const scope = Array.isArray(stored.scope)
|
|
821
|
-
? stored.scope
|
|
822
|
-
: typeof stored.scope === 'string'
|
|
823
|
-
? stored.scope.split(/\s+/).filter((entry) => entry.length > 0)
|
|
824
|
-
: [];
|
|
954
|
+
const scope = Array.isArray(stored.scope) ? stored.scope : [];
|
|
825
955
|
return [200, this.buildTokenResponse(tokens, client, scope)];
|
|
826
956
|
}
|
|
827
957
|
clearOAuthCookies(apiReq) {
|
|
@@ -884,19 +1014,16 @@ class AuthModule extends BaseAuthModule {
|
|
|
884
1014
|
if (!client) {
|
|
885
1015
|
throw new ApiError({ code: 400, message: 'Unknown client_id' });
|
|
886
1016
|
}
|
|
887
|
-
const requiresSecret =
|
|
1017
|
+
const requiresSecret = client.hasSecret ?? Boolean(client.clientSecret);
|
|
888
1018
|
if (requiresSecret) {
|
|
889
1019
|
if (!secretProvided) {
|
|
890
1020
|
throw new ApiError({ code: 400, message: 'Client authentication is required' });
|
|
891
1021
|
}
|
|
892
|
-
|
|
893
|
-
if (this.
|
|
894
|
-
|
|
895
|
-
valid = await verifySecret(client, clientSecret);
|
|
896
|
-
}
|
|
897
|
-
else {
|
|
898
|
-
valid = client.clientSecret === clientSecret;
|
|
1022
|
+
const verifySecret = this.storage.verifyClientSecret;
|
|
1023
|
+
if (typeof verifySecret !== 'function' || !this.storageImplements('verifyClientSecret')) {
|
|
1024
|
+
throw new ApiError({ code: 501, message: 'OAuth client secret verification is not configured' });
|
|
899
1025
|
}
|
|
1026
|
+
const valid = await verifySecret.call(this.storage, client, clientSecret);
|
|
900
1027
|
if (!valid) {
|
|
901
1028
|
throw new ApiError({ code: 401, message: 'Invalid client credentials' });
|
|
902
1029
|
}
|
|
@@ -923,63 +1050,118 @@ class AuthModule extends BaseAuthModule {
|
|
|
923
1050
|
const password = toStringOrNull(body.password);
|
|
924
1051
|
if (login && password) {
|
|
925
1052
|
const user = await this.storage.getUser(login);
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
const verified = await this.storage.verifyPassword(password, hash);
|
|
931
|
-
if (!verified) {
|
|
932
|
-
throw new ApiError({
|
|
933
|
-
code: 400,
|
|
934
|
-
message: 'Invalid credentials',
|
|
935
|
-
errors: { password: 'Wrong password' }
|
|
936
|
-
});
|
|
1053
|
+
const hash = user ? this.storage.getUserPasswordHash(user) : '';
|
|
1054
|
+
const verified = user ? await this.storage.verifyPassword(password, hash) : false;
|
|
1055
|
+
if (!user || !verified) {
|
|
1056
|
+
throw new ApiError({ code: 400, message: 'Invalid credentials' });
|
|
937
1057
|
}
|
|
938
1058
|
return user;
|
|
939
1059
|
}
|
|
940
1060
|
throw new ApiError({ code: 401, message: 'Authorization requires user authentication' });
|
|
941
1061
|
}
|
|
942
|
-
|
|
943
|
-
const
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
1062
|
+
hasPasskeyService() {
|
|
1063
|
+
const storageHints = this.storage;
|
|
1064
|
+
if (storageHints.passkeyService || storageHints.passkeyStore) {
|
|
1065
|
+
return true;
|
|
1066
|
+
}
|
|
1067
|
+
if (storageHints.adapter?.passkeyService || storageHints.adapter?.passkeyStore) {
|
|
1068
|
+
return true;
|
|
1069
|
+
}
|
|
1070
|
+
const serverHints = this.server;
|
|
1071
|
+
return !!serverHints.passkeyServiceAdapter;
|
|
1072
|
+
}
|
|
1073
|
+
hasOAuthStore() {
|
|
1074
|
+
const storageHints = this.storage;
|
|
1075
|
+
if (storageHints.oauthStore) {
|
|
1076
|
+
return true;
|
|
1077
|
+
}
|
|
1078
|
+
if (storageHints.adapter?.oauthStore) {
|
|
1079
|
+
return true;
|
|
1080
|
+
}
|
|
1081
|
+
const serverHints = this.server;
|
|
1082
|
+
return !!serverHints.oauthStoreAdapter;
|
|
1083
|
+
}
|
|
1084
|
+
storageImplements(key) {
|
|
1085
|
+
const candidate = this.storage[key];
|
|
1086
|
+
if (typeof candidate !== 'function') {
|
|
1087
|
+
return false;
|
|
1088
|
+
}
|
|
1089
|
+
const baseImpl = BaseAuthAdapter.prototype[key];
|
|
1090
|
+
return candidate !== baseImpl;
|
|
1091
|
+
}
|
|
1092
|
+
storageImplementsAll(keys) {
|
|
1093
|
+
return keys.every((key) => this.storageImplements(key));
|
|
1094
|
+
}
|
|
1095
|
+
async applyRateLimit(apiReq, endpoint) {
|
|
1096
|
+
if (!this.rateLimitHook) {
|
|
1097
|
+
return;
|
|
1098
|
+
}
|
|
1099
|
+
await this.rateLimitHook({ apiReq, endpoint });
|
|
1100
|
+
}
|
|
1101
|
+
resolvePkceChallengeMethod(value) {
|
|
1102
|
+
if (value === 'S256') {
|
|
1103
|
+
return 'S256';
|
|
1104
|
+
}
|
|
1105
|
+
if (value === 'plain') {
|
|
1106
|
+
if (!this.allowInsecurePkcePlain) {
|
|
1107
|
+
throw new ApiError({ code: 400, message: 'PKCE plain is not permitted' });
|
|
979
1108
|
}
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
1109
|
+
return 'plain';
|
|
1110
|
+
}
|
|
1111
|
+
return undefined;
|
|
1112
|
+
}
|
|
1113
|
+
defineRoutes() {
|
|
1114
|
+
const routes = [];
|
|
1115
|
+
const coreAuthSupported = this.storageImplementsAll([
|
|
1116
|
+
'getUser',
|
|
1117
|
+
'getUserPasswordHash',
|
|
1118
|
+
'getUserId',
|
|
1119
|
+
'verifyPassword',
|
|
1120
|
+
'filterUser',
|
|
1121
|
+
'storeToken',
|
|
1122
|
+
'getToken',
|
|
1123
|
+
'deleteToken'
|
|
1124
|
+
]);
|
|
1125
|
+
if (!coreAuthSupported) {
|
|
1126
|
+
return routes;
|
|
1127
|
+
}
|
|
1128
|
+
routes.push({
|
|
1129
|
+
method: 'post',
|
|
1130
|
+
path: '/v1/login',
|
|
1131
|
+
handler: (req) => this.postLogin(req),
|
|
1132
|
+
auth: { type: 'none', req: 'any' }
|
|
1133
|
+
}, {
|
|
1134
|
+
method: 'post',
|
|
1135
|
+
path: '/v1/refresh',
|
|
1136
|
+
handler: (req) => this.postRefresh(req),
|
|
1137
|
+
auth: { type: 'none', req: 'any' }
|
|
1138
|
+
}, {
|
|
1139
|
+
method: 'post',
|
|
1140
|
+
path: '/v1/logout',
|
|
1141
|
+
handler: (req) => this.postLogout(req),
|
|
1142
|
+
auth: { type: 'maybe', req: 'any' }
|
|
1143
|
+
}, {
|
|
1144
|
+
method: 'post',
|
|
1145
|
+
path: '/v1/whoami',
|
|
1146
|
+
handler: (req) => this.postWhoAmI(req),
|
|
1147
|
+
auth: { type: 'maybe', req: 'any' }
|
|
1148
|
+
}, {
|
|
1149
|
+
method: 'post',
|
|
1150
|
+
path: '/v1/impersonations',
|
|
1151
|
+
handler: (req) => this.postImpersonation(req),
|
|
1152
|
+
auth: { type: 'strict', req: 'any' }
|
|
1153
|
+
}, {
|
|
1154
|
+
method: 'delete',
|
|
1155
|
+
path: '/v1/impersonations',
|
|
1156
|
+
handler: (req) => this.deleteImpersonation(req),
|
|
1157
|
+
auth: { type: 'strict', req: 'any' }
|
|
1158
|
+
});
|
|
1159
|
+
const passkeysSupported = this.hasPasskeyService() &&
|
|
1160
|
+
this.storageImplements('createPasskeyChallenge') &&
|
|
1161
|
+
this.storageImplements('verifyPasskeyResponse');
|
|
1162
|
+
const passkeyCredentialsSupported = passkeysSupported &&
|
|
1163
|
+
this.storageImplements('listUserCredentials') &&
|
|
1164
|
+
this.storageImplements('deletePasskeyCredential');
|
|
983
1165
|
if (passkeysSupported) {
|
|
984
1166
|
routes.push({
|
|
985
1167
|
method: 'post',
|
|
@@ -992,6 +1174,19 @@ class AuthModule extends BaseAuthModule {
|
|
|
992
1174
|
handler: (req) => this.postPasskeyVerify(req),
|
|
993
1175
|
auth: { type: 'none', req: 'any' }
|
|
994
1176
|
});
|
|
1177
|
+
if (passkeyCredentialsSupported) {
|
|
1178
|
+
routes.push({
|
|
1179
|
+
method: 'get',
|
|
1180
|
+
path: '/v1/passkeys',
|
|
1181
|
+
handler: (req) => this.getPasskeys(req),
|
|
1182
|
+
auth: { type: 'strict', req: 'any' }
|
|
1183
|
+
}, {
|
|
1184
|
+
method: 'delete',
|
|
1185
|
+
path: '/v1/passkeys/:credentialId',
|
|
1186
|
+
handler: (req) => this.deletePasskey(req),
|
|
1187
|
+
auth: { type: 'strict', req: 'any' }
|
|
1188
|
+
});
|
|
1189
|
+
}
|
|
995
1190
|
}
|
|
996
1191
|
const externalOAuthSupported = typeof this.server.initiateOAuth === 'function' && typeof this.server.completeOAuth === 'function';
|
|
997
1192
|
if (externalOAuthSupported) {
|
|
@@ -1007,9 +1202,10 @@ class AuthModule extends BaseAuthModule {
|
|
|
1007
1202
|
auth: { type: 'none', req: 'any' }
|
|
1008
1203
|
});
|
|
1009
1204
|
}
|
|
1010
|
-
const oauthStorageSupported =
|
|
1011
|
-
|
|
1012
|
-
|
|
1205
|
+
const oauthStorageSupported = this.hasOAuthStore() &&
|
|
1206
|
+
this.storageImplements('getClient') &&
|
|
1207
|
+
this.storageImplements('createAuthCode') &&
|
|
1208
|
+
this.storageImplements('consumeAuthCode');
|
|
1013
1209
|
if (oauthStorageSupported) {
|
|
1014
1210
|
routes.push({
|
|
1015
1211
|
method: 'post',
|