@technomoron/api-server-base 2.0.0-beta.22 → 2.0.0-beta.24
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-module.cjs +8 -0
- package/dist/cjs/api-module.d.ts +12 -0
- package/dist/cjs/api-server-base.cjs +573 -615
- package/dist/cjs/api-server-base.d.ts +97 -87
- package/dist/cjs/auth-api/{auth-module.js → auth-module.cjs} +96 -76
- package/dist/cjs/auth-api/auth-module.d.ts +1 -1
- package/dist/cjs/auth-api/{compat-auth-storage.js → compat-auth-storage.cjs} +4 -4
- package/dist/cjs/auth-api/{mem-auth-store.js → mem-auth-store.cjs} +7 -7
- package/dist/cjs/auth-api/{module.js → module.cjs} +1 -1
- package/dist/cjs/auth-api/schemas.cjs +171 -0
- package/dist/cjs/auth-api/schemas.d.ts +21 -0
- package/dist/cjs/auth-api/{sql-auth-store.js → sql-auth-store.cjs} +8 -8
- package/dist/cjs/auth-api/{user-id.js → user-id.cjs} +12 -3
- package/dist/cjs/auth-cookie-options.d.ts +5 -3
- package/dist/cjs/base/client-info.cjs +285 -0
- package/dist/cjs/base/client-info.d.ts +27 -0
- package/dist/cjs/base/error-utils.cjs +50 -0
- package/dist/cjs/base/error-utils.d.ts +16 -0
- package/dist/cjs/base/request-utils.cjs +27 -0
- package/dist/cjs/base/request-utils.d.ts +8 -0
- package/dist/cjs/index.cjs +24 -15
- package/dist/cjs/index.d.ts +7 -0
- package/dist/cjs/limiter/auth-rate-limiter.cjs +35 -0
- package/dist/cjs/limiter/auth-rate-limiter.d.ts +12 -0
- package/dist/cjs/limiter/fixed-window.cjs +41 -0
- package/dist/cjs/limiter/fixed-window.d.ts +11 -0
- package/dist/cjs/oauth/{base.js → base.cjs} +1 -0
- package/dist/cjs/oauth/base.d.ts +8 -1
- package/dist/cjs/oauth/{memory.js → memory.cjs} +7 -4
- package/dist/cjs/oauth/memory.d.ts +1 -1
- package/dist/cjs/oauth/{models.js → models.cjs} +2 -2
- package/dist/cjs/oauth/{sequelize.js → sequelize.cjs} +11 -7
- package/dist/cjs/oauth/sequelize.d.ts +1 -1
- package/dist/cjs/passkey/{base.js → base.cjs} +1 -0
- package/dist/cjs/passkey/base.d.ts +11 -0
- package/dist/cjs/passkey/{memory.js → memory.cjs} +2 -2
- package/dist/cjs/passkey/{models.js → models.cjs} +1 -1
- package/dist/cjs/passkey/{sequelize.js → sequelize.cjs} +3 -3
- package/dist/cjs/passkey/{service.js → service.cjs} +17 -3
- package/dist/cjs/passkey/service.d.ts +1 -1
- package/dist/cjs/{sequelize-utils.js → sequelize-utils.cjs} +4 -5
- package/dist/cjs/token/{base.js → base.cjs} +4 -0
- package/dist/cjs/token/base.d.ts +7 -0
- package/dist/cjs/token/{memory.js → memory.cjs} +15 -20
- package/dist/cjs/token/{sequelize.js → sequelize.cjs} +25 -11
- package/dist/cjs/upload/memory.cjs +92 -0
- package/dist/cjs/upload/memory.d.ts +17 -0
- package/dist/cjs/upload/tus-module.cjs +270 -0
- package/dist/cjs/upload/tus-module.d.ts +38 -0
- package/dist/cjs/upload/types.d.ts +28 -0
- package/dist/cjs/user/{base.js → base.cjs} +1 -0
- package/dist/cjs/user/base.d.ts +9 -0
- package/dist/cjs/user/{memory.js → memory.cjs} +29 -7
- package/dist/cjs/user/{sequelize.js → sequelize.cjs} +33 -8
- package/dist/cjs/user/types.cjs +2 -0
- package/dist/esm/api-module.d.ts +12 -0
- package/dist/esm/api-module.js +8 -0
- package/dist/esm/api-server-base.d.ts +97 -87
- package/dist/esm/api-server-base.js +562 -604
- package/dist/esm/auth-api/auth-module.d.ts +1 -1
- package/dist/esm/auth-api/auth-module.js +92 -72
- package/dist/esm/auth-api/compat-auth-storage.js +3 -3
- package/dist/esm/auth-api/schemas.d.ts +21 -0
- package/dist/esm/auth-api/schemas.js +168 -0
- package/dist/esm/auth-api/user-id.js +12 -3
- package/dist/esm/auth-cookie-options.d.ts +5 -3
- package/dist/esm/base/client-info.d.ts +27 -0
- package/dist/esm/base/client-info.js +282 -0
- package/dist/esm/base/error-utils.d.ts +16 -0
- package/dist/esm/base/error-utils.js +44 -0
- package/dist/esm/base/request-utils.d.ts +8 -0
- package/dist/esm/base/request-utils.js +23 -0
- package/dist/esm/index.d.ts +7 -0
- package/dist/esm/index.js +4 -0
- package/dist/esm/limiter/auth-rate-limiter.d.ts +12 -0
- package/dist/esm/limiter/auth-rate-limiter.js +32 -0
- package/dist/esm/limiter/fixed-window.d.ts +11 -0
- package/dist/esm/limiter/fixed-window.js +37 -0
- package/dist/esm/oauth/base.d.ts +8 -1
- package/dist/esm/oauth/base.js +1 -0
- package/dist/esm/oauth/memory.d.ts +1 -1
- package/dist/esm/oauth/memory.js +5 -2
- package/dist/esm/oauth/sequelize.d.ts +1 -1
- package/dist/esm/oauth/sequelize.js +6 -2
- package/dist/esm/passkey/base.d.ts +11 -0
- package/dist/esm/passkey/base.js +1 -0
- package/dist/esm/passkey/service.d.ts +1 -1
- package/dist/esm/passkey/service.js +17 -3
- package/dist/esm/sequelize-utils.js +4 -5
- package/dist/esm/token/base.d.ts +7 -0
- package/dist/esm/token/base.js +4 -0
- package/dist/esm/token/memory.js +14 -19
- package/dist/esm/token/sequelize.js +22 -8
- package/dist/esm/upload/memory.d.ts +17 -0
- package/dist/esm/upload/memory.js +86 -0
- package/dist/esm/upload/tus-module.d.ts +38 -0
- package/dist/esm/upload/tus-module.js +266 -0
- package/dist/esm/upload/types.d.ts +28 -0
- package/dist/esm/upload/types.js +1 -0
- package/dist/esm/user/base.d.ts +9 -0
- package/dist/esm/user/base.js +1 -0
- package/dist/esm/user/memory.js +27 -5
- package/dist/esm/user/sequelize.js +30 -5
- package/docs/swagger/openapi.json +1 -1
- package/package.json +18 -17
- package/README.txt +0 -216
- /package/dist/cjs/auth-api/{storage.js → storage.cjs} +0 -0
- /package/dist/cjs/auth-api/{types.js → types.cjs} +0 -0
- /package/dist/cjs/{auth-cookie-options.js → auth-cookie-options.cjs} +0 -0
- /package/dist/cjs/oauth/{types.js → types.cjs} +0 -0
- /package/dist/cjs/passkey/{config.js → config.cjs} +0 -0
- /package/dist/cjs/passkey/{types.js → types.cjs} +0 -0
- /package/dist/cjs/token/{types.js → types.cjs} +0 -0
- /package/dist/cjs/{user/types.js → upload/types.cjs} +0 -0
|
@@ -10,7 +10,7 @@ interface CanImpersonateContext<UserEntity> {
|
|
|
10
10
|
targetUser: UserEntity;
|
|
11
11
|
effectiveUserId: AuthIdentifier;
|
|
12
12
|
}
|
|
13
|
-
type AuthRateLimitEndpoint = 'login' | 'passkey-challenge' | 'oauth-token';
|
|
13
|
+
type AuthRateLimitEndpoint = 'login' | 'passkey-challenge' | 'oauth-token' | 'oauth-authorize';
|
|
14
14
|
interface AuthModuleOptions<UserEntity> {
|
|
15
15
|
namespace?: string;
|
|
16
16
|
defaultDomain?: string;
|
|
@@ -3,9 +3,14 @@ import { isoBase64URL } from '@simplewebauthn/server/helpers';
|
|
|
3
3
|
import { ApiError } from '../api-server-base.js';
|
|
4
4
|
import { buildAuthCookieOptions } from '../auth-cookie-options.js';
|
|
5
5
|
import { BaseAuthModule } from './module.js';
|
|
6
|
+
import { loginBodySchema, refreshBodySchema, logoutBodySchema, whoamiBodySchema, passkeyChallengeBodySchema, passkeyVerifyBodySchema, passkeyCredentialParamsSchema, impersonateBodySchema, deleteImpersonationQuerySchema, oauthProviderParamsSchema, oauthStartBodySchema, oauthAuthorizeBodySchema, oauthTokenBodySchema } from './schemas.js';
|
|
6
7
|
import { BaseAuthAdapter } from './storage.js';
|
|
7
8
|
function isAuthIdentifier(value) {
|
|
8
|
-
|
|
9
|
+
if (typeof value === 'string')
|
|
10
|
+
return value.length > 0;
|
|
11
|
+
if (typeof value === 'number')
|
|
12
|
+
return Number.isFinite(value);
|
|
13
|
+
return false;
|
|
9
14
|
}
|
|
10
15
|
function toStringOrNull(value) {
|
|
11
16
|
if (typeof value === 'string') {
|
|
@@ -42,7 +47,7 @@ class AuthModule extends BaseAuthModule {
|
|
|
42
47
|
this.defaultDomain = options.defaultDomain;
|
|
43
48
|
this.canImpersonateHook = options.canImpersonate;
|
|
44
49
|
this.rateLimitHook = options.rateLimit;
|
|
45
|
-
this.allowInsecurePkcePlain = options.allowInsecurePkcePlain ??
|
|
50
|
+
this.allowInsecurePkcePlain = options.allowInsecurePkcePlain ?? false;
|
|
46
51
|
}
|
|
47
52
|
get storage() {
|
|
48
53
|
return this.server.getAuthStorage();
|
|
@@ -58,16 +63,17 @@ class AuthModule extends BaseAuthModule {
|
|
|
58
63
|
targetUser,
|
|
59
64
|
effectiveUserId
|
|
60
65
|
});
|
|
61
|
-
if (allowed)
|
|
66
|
+
if (allowed === true)
|
|
62
67
|
return true;
|
|
63
|
-
|
|
68
|
+
if (allowed === false)
|
|
69
|
+
return false;
|
|
64
70
|
}
|
|
65
71
|
const storageWithHook = this.storage;
|
|
66
72
|
if (typeof storageWithHook.canImpersonate === 'function') {
|
|
67
73
|
const allowed = await storageWithHook.canImpersonate({ realUserId, effectiveUserId });
|
|
68
74
|
return !!allowed;
|
|
69
75
|
}
|
|
70
|
-
return realUserId === effectiveUserId;
|
|
76
|
+
return String(realUserId) === String(effectiveUserId);
|
|
71
77
|
}
|
|
72
78
|
async ensureImpersonationAllowed(apiReq, realUser, targetUser) {
|
|
73
79
|
const permitted = await this.canImpersonate(apiReq, realUser, targetUser);
|
|
@@ -255,6 +261,9 @@ class AuthModule extends BaseAuthModule {
|
|
|
255
261
|
const accessMaxAge = Math.max(1, conf.accessExpiry) * 1000;
|
|
256
262
|
const refreshSeconds = Math.max(1, preferences.refreshTtlSeconds ?? conf.refreshExpiry);
|
|
257
263
|
const refreshMaxAge = refreshSeconds * 1000;
|
|
264
|
+
// When sessionCookie is true we omit maxAge so the browser deletes the
|
|
265
|
+
// cookie on close. The server-side JWT still has its own expiry, which
|
|
266
|
+
// limits exposure if the browser crashes without running its cleanup.
|
|
258
267
|
const accessOptions = sessionCookie ? options : { ...options, maxAge: accessMaxAge };
|
|
259
268
|
const refreshOptions = sessionCookie ? options : { ...options, maxAge: refreshMaxAge };
|
|
260
269
|
if (tokens.accessToken) {
|
|
@@ -344,19 +353,10 @@ class AuthModule extends BaseAuthModule {
|
|
|
344
353
|
}
|
|
345
354
|
parseLoginBody(apiReq) {
|
|
346
355
|
const body = (apiReq.req.body ?? {});
|
|
347
|
-
|
|
348
|
-
const
|
|
356
|
+
// login and password are guaranteed present and non-empty by JSON Schema
|
|
357
|
+
const login = body.login;
|
|
358
|
+
const password = body.password;
|
|
349
359
|
const sessionPrefs = this.resolveSessionPreferences(body.keepSession);
|
|
350
|
-
if (!login || !password) {
|
|
351
|
-
const errors = {};
|
|
352
|
-
if (!login) {
|
|
353
|
-
errors.login = 'Login is required';
|
|
354
|
-
}
|
|
355
|
-
if (!password) {
|
|
356
|
-
errors.password = 'Password is required';
|
|
357
|
-
}
|
|
358
|
-
throw new ApiError({ code: 400, message: 'Missing credentials', errors });
|
|
359
|
-
}
|
|
360
360
|
return {
|
|
361
361
|
login,
|
|
362
362
|
password,
|
|
@@ -451,7 +451,9 @@ class AuthModule extends BaseAuthModule {
|
|
|
451
451
|
const { login, password, ...metadata } = this.parseLoginBody(apiReq);
|
|
452
452
|
const user = await this.storage.getUser(login);
|
|
453
453
|
const hash = user ? this.storage.getUserPasswordHash(user) : '';
|
|
454
|
-
|
|
454
|
+
// Reject users with no password hash (e.g. OAuth/passkey-only accounts) before
|
|
455
|
+
// calling verifyPassword, since bcrypt behaviour on an empty hash is undefined.
|
|
456
|
+
const verified = user && hash ? await this.storage.verifyPassword(password, hash) : false;
|
|
455
457
|
if (!user || !verified) {
|
|
456
458
|
throw new ApiError({
|
|
457
459
|
code: 400,
|
|
@@ -482,6 +484,13 @@ class AuthModule extends BaseAuthModule {
|
|
|
482
484
|
message: verify.error ?? 'Unable to verify refresh token'
|
|
483
485
|
});
|
|
484
486
|
}
|
|
487
|
+
// Delete the token immediately after verification to narrow the TOCTOU window.
|
|
488
|
+
// This must happen before the slower getUserOrThrow call.
|
|
489
|
+
const deleted = await this.storage.deleteToken({ refreshToken: providedToken });
|
|
490
|
+
if (deleted === 0) {
|
|
491
|
+
// Another concurrent request already consumed this refresh token.
|
|
492
|
+
throw new ApiError({ code: 401, message: 'Invalid refresh token' });
|
|
493
|
+
}
|
|
485
494
|
const user = await this.getUserOrThrow(stored.userId ?? verify.data.uid, 'User not found');
|
|
486
495
|
const metadata = {
|
|
487
496
|
domain: stored.domain,
|
|
@@ -497,7 +506,6 @@ class AuthModule extends BaseAuthModule {
|
|
|
497
506
|
refreshTtlSeconds: sessionPrefs.refreshTtlSeconds ?? stored.refreshTtlSeconds,
|
|
498
507
|
sessionCookie: sessionPrefs.sessionCookie ?? stored.sessionCookie
|
|
499
508
|
};
|
|
500
|
-
await this.storage.deleteToken({ refreshToken: providedToken });
|
|
501
509
|
const pair = await this.issueTokens(apiReq, user, metadata);
|
|
502
510
|
const publicUser = this.storage.filterUser(user);
|
|
503
511
|
return [200, { ...pair, user: publicUser }];
|
|
@@ -537,8 +545,7 @@ class AuthModule extends BaseAuthModule {
|
|
|
537
545
|
apiReq.req.cookies[conf.accessCookie].trim().length > 0);
|
|
538
546
|
const shouldRefresh = Boolean(body.refresh) || !hasAccessToken;
|
|
539
547
|
if (shouldRefresh) {
|
|
540
|
-
|
|
541
|
-
if (typeof updateToken !== 'function' || !this.storageImplements('updateToken')) {
|
|
548
|
+
if (typeof this.storage.updateToken !== 'function' || !this.storageImplements('updateToken')) {
|
|
542
549
|
throw new ApiError({ code: 501, message: 'Token update storage is not configured' });
|
|
543
550
|
}
|
|
544
551
|
// Sign a new access token without embedding stored token secrets into the JWT payload.
|
|
@@ -562,7 +569,7 @@ class AuthModule extends BaseAuthModule {
|
|
|
562
569
|
if (!access.success || !access.token) {
|
|
563
570
|
throw new ApiError({ code: 500, message: access.error ?? 'Unable to sign access token' });
|
|
564
571
|
}
|
|
565
|
-
const updated = await
|
|
572
|
+
const updated = await this.storage.updateToken({
|
|
566
573
|
refreshToken,
|
|
567
574
|
accessToken: access.token,
|
|
568
575
|
lastSeenAt: new Date()
|
|
@@ -608,12 +615,10 @@ class AuthModule extends BaseAuthModule {
|
|
|
608
615
|
throw new ApiError({ code: 501, message: 'Passkey support is not configured' });
|
|
609
616
|
}
|
|
610
617
|
const body = (apiReq.req.body ?? {});
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
throw new ApiError({ code: 400, message: 'Passkey action must be "register" or "authenticate"' });
|
|
614
|
-
}
|
|
618
|
+
// action is guaranteed to be 'register' | 'authenticate' by JSON Schema
|
|
619
|
+
const action = body.action;
|
|
615
620
|
const params = {
|
|
616
|
-
action,
|
|
621
|
+
action: action,
|
|
617
622
|
login: toStringOrNull(body.login) ?? undefined,
|
|
618
623
|
userId: isAuthIdentifier(body.userId) ? body.userId : undefined
|
|
619
624
|
};
|
|
@@ -626,11 +631,9 @@ class AuthModule extends BaseAuthModule {
|
|
|
626
631
|
}
|
|
627
632
|
const body = (apiReq.req.body ?? {});
|
|
628
633
|
const sessionPrefs = this.resolveSessionPreferences(body.keepSession);
|
|
629
|
-
|
|
634
|
+
// expectedChallenge (string) and response (object) are guaranteed by JSON Schema
|
|
635
|
+
const expectedChallenge = body.expectedChallenge;
|
|
630
636
|
const response = body.response;
|
|
631
|
-
if (!expectedChallenge || typeof response !== 'object' || response === null) {
|
|
632
|
-
throw new ApiError({ code: 400, message: 'Malformed passkey verification payload' });
|
|
633
|
-
}
|
|
634
637
|
const rawMetadata = {
|
|
635
638
|
domain: toStringOrNull(body.domain) ?? undefined,
|
|
636
639
|
fingerprint: toStringOrNull(body.fingerprint) ?? undefined,
|
|
@@ -740,6 +743,15 @@ class AuthModule extends BaseAuthModule {
|
|
|
740
743
|
}
|
|
741
744
|
async deleteImpersonation(apiReq) {
|
|
742
745
|
this.assertAuthReady();
|
|
746
|
+
if (!apiReq.isImpersonating()) {
|
|
747
|
+
throw new ApiError({ code: 400, message: 'Not currently impersonating' });
|
|
748
|
+
}
|
|
749
|
+
// Revoke the active impersonation refresh token before issuing new real-user tokens
|
|
750
|
+
// so that a captured impersonation token cannot be reused after impersonation ends.
|
|
751
|
+
const impersonationRefreshToken = this.extractRefreshToken(apiReq, {});
|
|
752
|
+
if (impersonationRefreshToken) {
|
|
753
|
+
await this.storage.deleteToken({ refreshToken: impersonationRefreshToken });
|
|
754
|
+
}
|
|
743
755
|
const actor = await this.resolveActorContext(apiReq);
|
|
744
756
|
const query = (apiReq.req.query ?? {});
|
|
745
757
|
const metadata = this.buildImpersonationMetadata(query);
|
|
@@ -776,9 +788,7 @@ class AuthModule extends BaseAuthModule {
|
|
|
776
788
|
? apiReq.req.body.extras
|
|
777
789
|
: undefined
|
|
778
790
|
};
|
|
779
|
-
|
|
780
|
-
throw new ApiError({ code: 400, message: 'OAuth provider is required' });
|
|
781
|
-
}
|
|
791
|
+
// provider is guaranteed present and non-empty by params schema
|
|
782
792
|
const result = await this.server.initiateOAuth(params);
|
|
783
793
|
return [200, result];
|
|
784
794
|
}
|
|
@@ -791,9 +801,6 @@ class AuthModule extends BaseAuthModule {
|
|
|
791
801
|
query: apiReq.req.query,
|
|
792
802
|
body: (apiReq.req.body ?? {})
|
|
793
803
|
};
|
|
794
|
-
if (!params.provider) {
|
|
795
|
-
throw new ApiError({ code: 400, message: 'OAuth provider is required' });
|
|
796
|
-
}
|
|
797
804
|
const result = await this.server.completeOAuth(params);
|
|
798
805
|
if (result.tokens?.accessToken && result.tokens.refreshToken) {
|
|
799
806
|
this.setJwtCookies(apiReq, {
|
|
@@ -807,20 +814,16 @@ class AuthModule extends BaseAuthModule {
|
|
|
807
814
|
if (typeof this.storage.getClient !== 'function' || typeof this.storage.createAuthCode !== 'function') {
|
|
808
815
|
throw new ApiError({ code: 501, message: 'OAuth authorization storage is not configured' });
|
|
809
816
|
}
|
|
817
|
+
await this.applyRateLimit(apiReq, 'oauth-authorize');
|
|
810
818
|
const body = (apiReq.req.body ?? {});
|
|
811
|
-
|
|
812
|
-
const
|
|
819
|
+
// clientId and redirectUri are guaranteed present and non-empty by JSON Schema
|
|
820
|
+
const clientId = body.clientId;
|
|
821
|
+
const redirectUri = body.redirectUri;
|
|
813
822
|
const scope = toScopeArray(body.scope) ?? [];
|
|
814
823
|
const state = toStringOrNull(body.state) ?? undefined;
|
|
815
824
|
const codeChallenge = toStringOrNull(body.codeChallenge) ?? undefined;
|
|
816
825
|
const codeChallengeMethod = toStringOrNull(body.codeChallengeMethod) ?? undefined;
|
|
817
826
|
const resolvedCodeChallengeMethod = this.resolvePkceChallengeMethod(codeChallengeMethod);
|
|
818
|
-
if (!clientId) {
|
|
819
|
-
throw new ApiError({ code: 400, message: 'clientId is required' });
|
|
820
|
-
}
|
|
821
|
-
if (!redirectUri) {
|
|
822
|
-
throw new ApiError({ code: 400, message: 'redirectUri is required' });
|
|
823
|
-
}
|
|
824
827
|
const client = await this.storage.getClient(clientId);
|
|
825
828
|
if (!client) {
|
|
826
829
|
throw new ApiError({ code: 400, message: 'Unknown client_id' });
|
|
@@ -850,10 +853,8 @@ class AuthModule extends BaseAuthModule {
|
|
|
850
853
|
throw new ApiError({ code: 501, message: 'OAuth token storage is not configured' });
|
|
851
854
|
}
|
|
852
855
|
const body = (apiReq.req.body ?? {});
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
throw new ApiError({ code: 400, message: 'grant_type is required' });
|
|
856
|
-
}
|
|
856
|
+
// grant_type is guaranteed to be 'authorization_code' | 'refresh_token' by JSON Schema
|
|
857
|
+
const grantType = body.grant_type;
|
|
857
858
|
const { client, clientSecretProvided } = await this.resolveClientAuthentication(apiReq, body);
|
|
858
859
|
switch (grantType) {
|
|
859
860
|
case 'authorization_code':
|
|
@@ -907,7 +908,7 @@ class AuthModule extends BaseAuthModule {
|
|
|
907
908
|
}
|
|
908
909
|
}
|
|
909
910
|
}
|
|
910
|
-
else if (!clientSecretProvided && (client.hasSecret ??
|
|
911
|
+
else if (!clientSecretProvided && (client.hasSecret ?? false)) {
|
|
911
912
|
throw new ApiError({ code: 400, message: 'Client authentication required when no PKCE challenge present' });
|
|
912
913
|
}
|
|
913
914
|
const user = await this.getUserOrThrow(record.userId, 'User not found');
|
|
@@ -941,8 +942,12 @@ class AuthModule extends BaseAuthModule {
|
|
|
941
942
|
if (stored.clientId && stored.clientId !== client.clientId) {
|
|
942
943
|
throw new ApiError({ code: 400, message: 'Refresh token issued to another client' });
|
|
943
944
|
}
|
|
945
|
+
// Delete the token immediately after verification to narrow the TOCTOU window.
|
|
946
|
+
const deleted = await this.storage.deleteToken({ refreshToken });
|
|
947
|
+
if (deleted === 0) {
|
|
948
|
+
throw new ApiError({ code: 401, message: 'Invalid refresh token' });
|
|
949
|
+
}
|
|
944
950
|
const user = await this.getUserOrThrow(stored.userId ?? verify.data.uid, 'User not found');
|
|
945
|
-
await this.storage.deleteToken({ refreshToken });
|
|
946
951
|
const tokens = await this.issueTokens(apiReq, user, {
|
|
947
952
|
clientId: client.clientId,
|
|
948
953
|
scope: stored.scope,
|
|
@@ -1014,7 +1019,7 @@ class AuthModule extends BaseAuthModule {
|
|
|
1014
1019
|
if (!client) {
|
|
1015
1020
|
throw new ApiError({ code: 400, message: 'Unknown client_id' });
|
|
1016
1021
|
}
|
|
1017
|
-
const requiresSecret = client.hasSecret ??
|
|
1022
|
+
const requiresSecret = client.hasSecret ?? false;
|
|
1018
1023
|
if (requiresSecret) {
|
|
1019
1024
|
if (!secretProvided) {
|
|
1020
1025
|
throw new ApiError({ code: 400, message: 'Client authentication is required' });
|
|
@@ -1032,7 +1037,7 @@ class AuthModule extends BaseAuthModule {
|
|
|
1032
1037
|
}
|
|
1033
1038
|
assertRedirectUriAllowed(client, redirectUri) {
|
|
1034
1039
|
if (client.redirectUris.length === 0) {
|
|
1035
|
-
|
|
1040
|
+
throw new ApiError({ code: 400, message: 'Client has no registered redirect URIs' });
|
|
1036
1041
|
}
|
|
1037
1042
|
if (!client.redirectUris.includes(redirectUri)) {
|
|
1038
1043
|
throw new ApiError({ code: 400, message: 'redirect_uri not registered for client' });
|
|
@@ -1041,9 +1046,13 @@ class AuthModule extends BaseAuthModule {
|
|
|
1041
1046
|
async resolveUserForOAuth(apiReq, body) {
|
|
1042
1047
|
const refreshToken = this.extractRefreshToken(apiReq, body);
|
|
1043
1048
|
if (refreshToken) {
|
|
1049
|
+
const verify = this.server.jwtVerify(refreshToken, this.server.config.refreshSecret);
|
|
1050
|
+
if (!verify.success || !verify.data) {
|
|
1051
|
+
throw new ApiError({ code: 401, message: 'Invalid or expired refresh token' });
|
|
1052
|
+
}
|
|
1044
1053
|
const stored = await this.storage.getToken({ refreshToken });
|
|
1045
1054
|
if (stored) {
|
|
1046
|
-
return this.getUserOrThrow(stored.userId, 'User not found for authorization');
|
|
1055
|
+
return this.getUserOrThrow(stored.userId ?? verify.data.uid, 'User not found for authorization');
|
|
1047
1056
|
}
|
|
1048
1057
|
}
|
|
1049
1058
|
const login = toStringOrNull(body.login);
|
|
@@ -1051,7 +1060,7 @@ class AuthModule extends BaseAuthModule {
|
|
|
1051
1060
|
if (login && password) {
|
|
1052
1061
|
const user = await this.storage.getUser(login);
|
|
1053
1062
|
const hash = user ? this.storage.getUserPasswordHash(user) : '';
|
|
1054
|
-
const verified = user ? await this.storage.verifyPassword(password, hash) : false;
|
|
1063
|
+
const verified = user && hash ? await this.storage.verifyPassword(password, hash) : false;
|
|
1055
1064
|
if (!user || !verified) {
|
|
1056
1065
|
throw new ApiError({ code: 400, message: 'Invalid credentials' });
|
|
1057
1066
|
}
|
|
@@ -1067,8 +1076,7 @@ class AuthModule extends BaseAuthModule {
|
|
|
1067
1076
|
if (storageHints.adapter?.passkeyService || storageHints.adapter?.passkeyStore) {
|
|
1068
1077
|
return true;
|
|
1069
1078
|
}
|
|
1070
|
-
|
|
1071
|
-
return !!serverHints.passkeyServiceAdapter;
|
|
1079
|
+
return false;
|
|
1072
1080
|
}
|
|
1073
1081
|
hasOAuthStore() {
|
|
1074
1082
|
const storageHints = this.storage;
|
|
@@ -1078,8 +1086,7 @@ class AuthModule extends BaseAuthModule {
|
|
|
1078
1086
|
if (storageHints.adapter?.oauthStore) {
|
|
1079
1087
|
return true;
|
|
1080
1088
|
}
|
|
1081
|
-
|
|
1082
|
-
return !!serverHints.oauthStoreAdapter;
|
|
1089
|
+
return false;
|
|
1083
1090
|
}
|
|
1084
1091
|
storageImplements(key) {
|
|
1085
1092
|
const candidate = this.storage[key];
|
|
@@ -1129,32 +1136,38 @@ class AuthModule extends BaseAuthModule {
|
|
|
1129
1136
|
method: 'post',
|
|
1130
1137
|
path: '/v1/login',
|
|
1131
1138
|
handler: (req) => this.postLogin(req),
|
|
1132
|
-
auth: { type: 'none', req: 'any' }
|
|
1139
|
+
auth: { type: 'none', req: 'any' },
|
|
1140
|
+
schema: { body: loginBodySchema }
|
|
1133
1141
|
}, {
|
|
1134
1142
|
method: 'post',
|
|
1135
1143
|
path: '/v1/refresh',
|
|
1136
1144
|
handler: (req) => this.postRefresh(req),
|
|
1137
|
-
auth: { type: 'none', req: 'any' }
|
|
1145
|
+
auth: { type: 'none', req: 'any' },
|
|
1146
|
+
schema: { body: refreshBodySchema }
|
|
1138
1147
|
}, {
|
|
1139
1148
|
method: 'post',
|
|
1140
1149
|
path: '/v1/logout',
|
|
1141
1150
|
handler: (req) => this.postLogout(req),
|
|
1142
|
-
auth: { type: 'maybe', req: 'any' }
|
|
1151
|
+
auth: { type: 'maybe', req: 'any' },
|
|
1152
|
+
schema: { body: logoutBodySchema }
|
|
1143
1153
|
}, {
|
|
1144
1154
|
method: 'post',
|
|
1145
1155
|
path: '/v1/whoami',
|
|
1146
1156
|
handler: (req) => this.postWhoAmI(req),
|
|
1147
|
-
auth: { type: 'maybe', req: 'any' }
|
|
1157
|
+
auth: { type: 'maybe', req: 'any' },
|
|
1158
|
+
schema: { body: whoamiBodySchema }
|
|
1148
1159
|
}, {
|
|
1149
1160
|
method: 'post',
|
|
1150
1161
|
path: '/v1/impersonations',
|
|
1151
1162
|
handler: (req) => this.postImpersonation(req),
|
|
1152
|
-
auth: { type: 'strict', req: 'any' }
|
|
1163
|
+
auth: { type: 'strict', req: 'any' },
|
|
1164
|
+
schema: { body: impersonateBodySchema }
|
|
1153
1165
|
}, {
|
|
1154
1166
|
method: 'delete',
|
|
1155
1167
|
path: '/v1/impersonations',
|
|
1156
1168
|
handler: (req) => this.deleteImpersonation(req),
|
|
1157
|
-
auth: { type: 'strict', req: 'any' }
|
|
1169
|
+
auth: { type: 'strict', req: 'any' },
|
|
1170
|
+
schema: { querystring: deleteImpersonationQuerySchema }
|
|
1158
1171
|
});
|
|
1159
1172
|
const passkeysSupported = this.hasPasskeyService() &&
|
|
1160
1173
|
this.storageImplements('createPasskeyChallenge') &&
|
|
@@ -1167,12 +1180,14 @@ class AuthModule extends BaseAuthModule {
|
|
|
1167
1180
|
method: 'post',
|
|
1168
1181
|
path: '/v1/passkeys/challenge',
|
|
1169
1182
|
handler: (req) => this.postPasskeyChallenge(req),
|
|
1170
|
-
auth: { type: 'none', req: 'any' }
|
|
1183
|
+
auth: { type: 'none', req: 'any' },
|
|
1184
|
+
schema: { body: passkeyChallengeBodySchema }
|
|
1171
1185
|
}, {
|
|
1172
1186
|
method: 'post',
|
|
1173
1187
|
path: '/v1/passkeys/verify',
|
|
1174
1188
|
handler: (req) => this.postPasskeyVerify(req),
|
|
1175
|
-
auth: { type: 'none', req: 'any' }
|
|
1189
|
+
auth: { type: 'none', req: 'any' },
|
|
1190
|
+
schema: { body: passkeyVerifyBodySchema }
|
|
1176
1191
|
});
|
|
1177
1192
|
if (passkeyCredentialsSupported) {
|
|
1178
1193
|
routes.push({
|
|
@@ -1184,7 +1199,8 @@ class AuthModule extends BaseAuthModule {
|
|
|
1184
1199
|
method: 'delete',
|
|
1185
1200
|
path: '/v1/passkeys/:credentialId',
|
|
1186
1201
|
handler: (req) => this.deletePasskey(req),
|
|
1187
|
-
auth: { type: 'strict', req: 'any' }
|
|
1202
|
+
auth: { type: 'strict', req: 'any' },
|
|
1203
|
+
schema: { params: passkeyCredentialParamsSchema }
|
|
1188
1204
|
});
|
|
1189
1205
|
}
|
|
1190
1206
|
}
|
|
@@ -1194,12 +1210,14 @@ class AuthModule extends BaseAuthModule {
|
|
|
1194
1210
|
method: 'post',
|
|
1195
1211
|
path: '/v1/oauth2/:provider/start',
|
|
1196
1212
|
handler: (req) => this.postOAuthStart(req),
|
|
1197
|
-
auth: { type: 'none', req: 'any' }
|
|
1213
|
+
auth: { type: 'none', req: 'any' },
|
|
1214
|
+
schema: { body: oauthStartBodySchema, params: oauthProviderParamsSchema }
|
|
1198
1215
|
}, {
|
|
1199
1216
|
method: 'post',
|
|
1200
1217
|
path: '/v1/oauth2/:provider/callback',
|
|
1201
1218
|
handler: (req) => this.postOAuthCallback(req),
|
|
1202
|
-
auth: { type: 'none', req: 'any' }
|
|
1219
|
+
auth: { type: 'none', req: 'any' },
|
|
1220
|
+
schema: { params: oauthProviderParamsSchema }
|
|
1203
1221
|
});
|
|
1204
1222
|
}
|
|
1205
1223
|
const oauthStorageSupported = this.hasOAuthStore() &&
|
|
@@ -1211,12 +1229,14 @@ class AuthModule extends BaseAuthModule {
|
|
|
1211
1229
|
method: 'post',
|
|
1212
1230
|
path: '/v1/oauth2/authorize',
|
|
1213
1231
|
handler: (req) => this.postOAuthAuthorize(req),
|
|
1214
|
-
auth: { type: 'maybe', req: 'any' }
|
|
1232
|
+
auth: { type: 'maybe', req: 'any' },
|
|
1233
|
+
schema: { body: oauthAuthorizeBodySchema }
|
|
1215
1234
|
}, {
|
|
1216
1235
|
method: 'post',
|
|
1217
1236
|
path: '/v1/oauth2/token',
|
|
1218
1237
|
handler: (req) => this.postOAuthToken(req),
|
|
1219
|
-
auth: { type: 'none', req: 'any' }
|
|
1238
|
+
auth: { type: 'none', req: 'any' },
|
|
1239
|
+
schema: { body: oauthTokenBodySchema }
|
|
1220
1240
|
});
|
|
1221
1241
|
}
|
|
1222
1242
|
return routes;
|
|
@@ -109,8 +109,8 @@ export class CompositeAuthAdapter {
|
|
|
109
109
|
if (!this.oauthStore) {
|
|
110
110
|
return null;
|
|
111
111
|
}
|
|
112
|
-
const consumed = await this.oauthStore.consumeAuthCode(code);
|
|
113
|
-
if (!consumed
|
|
112
|
+
const consumed = await this.oauthStore.consumeAuthCode(code, clientId);
|
|
113
|
+
if (!consumed) {
|
|
114
114
|
return null;
|
|
115
115
|
}
|
|
116
116
|
return consumed;
|
|
@@ -119,6 +119,6 @@ export class CompositeAuthAdapter {
|
|
|
119
119
|
if (this.canImpersonateFn) {
|
|
120
120
|
return !!(await this.canImpersonateFn(params));
|
|
121
121
|
}
|
|
122
|
-
return params.realUserId === params.effectiveUserId;
|
|
122
|
+
return String(params.realUserId) === String(params.effectiveUserId);
|
|
123
123
|
}
|
|
124
124
|
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* JSON Schema definitions for auth module routes.
|
|
3
|
+
* These are the runtime validation source-of-truth; TypeScript interfaces
|
|
4
|
+
* in auth-module.ts remain for handler-internal typing only.
|
|
5
|
+
*/
|
|
6
|
+
/** Loose JSON Schema object type used for Fastify route schema definitions. */
|
|
7
|
+
type JsonSchema = Record<string, unknown>;
|
|
8
|
+
export declare const loginBodySchema: JsonSchema;
|
|
9
|
+
export declare const refreshBodySchema: JsonSchema;
|
|
10
|
+
export declare const logoutBodySchema: JsonSchema;
|
|
11
|
+
export declare const whoamiBodySchema: JsonSchema;
|
|
12
|
+
export declare const passkeyChallengeBodySchema: JsonSchema;
|
|
13
|
+
export declare const passkeyVerifyBodySchema: JsonSchema;
|
|
14
|
+
export declare const passkeyCredentialParamsSchema: JsonSchema;
|
|
15
|
+
export declare const impersonateBodySchema: JsonSchema;
|
|
16
|
+
export declare const deleteImpersonationQuerySchema: JsonSchema;
|
|
17
|
+
export declare const oauthProviderParamsSchema: JsonSchema;
|
|
18
|
+
export declare const oauthStartBodySchema: JsonSchema;
|
|
19
|
+
export declare const oauthAuthorizeBodySchema: JsonSchema;
|
|
20
|
+
export declare const oauthTokenBodySchema: JsonSchema;
|
|
21
|
+
export {};
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* JSON Schema definitions for auth module routes.
|
|
3
|
+
* These are the runtime validation source-of-truth; TypeScript interfaces
|
|
4
|
+
* in auth-module.ts remain for handler-internal typing only.
|
|
5
|
+
*/
|
|
6
|
+
/* ------------------------------------------------------------------ */
|
|
7
|
+
/* Shared fragments */
|
|
8
|
+
/* ------------------------------------------------------------------ */
|
|
9
|
+
const tokenMetadataProperties = {
|
|
10
|
+
domain: { type: 'string' },
|
|
11
|
+
fingerprint: { type: 'string' },
|
|
12
|
+
label: { type: 'string' },
|
|
13
|
+
browser: { type: 'string' },
|
|
14
|
+
device: { type: 'string' },
|
|
15
|
+
ip: { type: 'string' },
|
|
16
|
+
os: { type: 'string' }
|
|
17
|
+
};
|
|
18
|
+
const keepSessionProperty = {
|
|
19
|
+
keepSession: { type: ['boolean', 'number', 'string'] }
|
|
20
|
+
};
|
|
21
|
+
function authIdentifierProperty(name) {
|
|
22
|
+
return { [name]: { type: ['string', 'number'] } };
|
|
23
|
+
}
|
|
24
|
+
/* ------------------------------------------------------------------ */
|
|
25
|
+
/* Auth route schemas */
|
|
26
|
+
/* ------------------------------------------------------------------ */
|
|
27
|
+
export const loginBodySchema = {
|
|
28
|
+
type: 'object',
|
|
29
|
+
required: ['login', 'password'],
|
|
30
|
+
properties: {
|
|
31
|
+
login: { type: 'string', minLength: 1 },
|
|
32
|
+
password: { type: 'string', minLength: 1 },
|
|
33
|
+
...tokenMetadataProperties,
|
|
34
|
+
...keepSessionProperty
|
|
35
|
+
},
|
|
36
|
+
additionalProperties: true
|
|
37
|
+
};
|
|
38
|
+
export const refreshBodySchema = {
|
|
39
|
+
type: 'object',
|
|
40
|
+
properties: {
|
|
41
|
+
refreshToken: { type: 'string' },
|
|
42
|
+
domain: { type: 'string' },
|
|
43
|
+
fingerprint: { type: 'string' },
|
|
44
|
+
label: { type: 'string' },
|
|
45
|
+
...keepSessionProperty
|
|
46
|
+
},
|
|
47
|
+
additionalProperties: false
|
|
48
|
+
};
|
|
49
|
+
export const logoutBodySchema = {
|
|
50
|
+
type: 'object',
|
|
51
|
+
properties: {
|
|
52
|
+
token: { type: 'string' },
|
|
53
|
+
refreshToken: { type: 'string' }
|
|
54
|
+
},
|
|
55
|
+
additionalProperties: false
|
|
56
|
+
};
|
|
57
|
+
export const whoamiBodySchema = {
|
|
58
|
+
type: 'object',
|
|
59
|
+
properties: {
|
|
60
|
+
refreshToken: { type: 'string' },
|
|
61
|
+
refresh: { type: 'boolean' }
|
|
62
|
+
},
|
|
63
|
+
additionalProperties: false
|
|
64
|
+
};
|
|
65
|
+
/* ------------------------------------------------------------------ */
|
|
66
|
+
/* Passkey schemas */
|
|
67
|
+
/* ------------------------------------------------------------------ */
|
|
68
|
+
export const passkeyChallengeBodySchema = {
|
|
69
|
+
type: 'object',
|
|
70
|
+
required: ['action'],
|
|
71
|
+
properties: {
|
|
72
|
+
action: { type: 'string', enum: ['register', 'authenticate'] },
|
|
73
|
+
login: { type: 'string' },
|
|
74
|
+
...authIdentifierProperty('userId')
|
|
75
|
+
},
|
|
76
|
+
additionalProperties: false
|
|
77
|
+
};
|
|
78
|
+
export const passkeyVerifyBodySchema = {
|
|
79
|
+
type: 'object',
|
|
80
|
+
required: ['expectedChallenge', 'response'],
|
|
81
|
+
properties: {
|
|
82
|
+
expectedChallenge: { type: 'string' },
|
|
83
|
+
response: { type: 'object' },
|
|
84
|
+
login: { type: 'string' },
|
|
85
|
+
...authIdentifierProperty('userId'),
|
|
86
|
+
userAgent: { type: 'string' },
|
|
87
|
+
...tokenMetadataProperties,
|
|
88
|
+
...keepSessionProperty
|
|
89
|
+
},
|
|
90
|
+
additionalProperties: true
|
|
91
|
+
};
|
|
92
|
+
export const passkeyCredentialParamsSchema = {
|
|
93
|
+
type: 'object',
|
|
94
|
+
required: ['credentialId'],
|
|
95
|
+
properties: {
|
|
96
|
+
credentialId: { type: 'string', minLength: 1 }
|
|
97
|
+
}
|
|
98
|
+
};
|
|
99
|
+
/* ------------------------------------------------------------------ */
|
|
100
|
+
/* Impersonation schemas */
|
|
101
|
+
/* ------------------------------------------------------------------ */
|
|
102
|
+
export const impersonateBodySchema = {
|
|
103
|
+
type: 'object',
|
|
104
|
+
properties: {
|
|
105
|
+
...authIdentifierProperty('userId'),
|
|
106
|
+
login: { type: 'string' },
|
|
107
|
+
...tokenMetadataProperties,
|
|
108
|
+
...keepSessionProperty,
|
|
109
|
+
clientId: { type: 'string' },
|
|
110
|
+
scope: {},
|
|
111
|
+
loginType: { type: 'string' }
|
|
112
|
+
},
|
|
113
|
+
additionalProperties: true
|
|
114
|
+
};
|
|
115
|
+
export const deleteImpersonationQuerySchema = {
|
|
116
|
+
type: 'object',
|
|
117
|
+
additionalProperties: true
|
|
118
|
+
};
|
|
119
|
+
/* ------------------------------------------------------------------ */
|
|
120
|
+
/* OAuth schemas */
|
|
121
|
+
/* ------------------------------------------------------------------ */
|
|
122
|
+
export const oauthProviderParamsSchema = {
|
|
123
|
+
type: 'object',
|
|
124
|
+
required: ['provider'],
|
|
125
|
+
properties: {
|
|
126
|
+
provider: { type: 'string', minLength: 1 }
|
|
127
|
+
}
|
|
128
|
+
};
|
|
129
|
+
export const oauthStartBodySchema = {
|
|
130
|
+
type: 'object',
|
|
131
|
+
properties: {
|
|
132
|
+
redirectUri: { type: 'string' },
|
|
133
|
+
scope: {},
|
|
134
|
+
state: { type: 'string' },
|
|
135
|
+
extras: { type: 'object' }
|
|
136
|
+
},
|
|
137
|
+
additionalProperties: true
|
|
138
|
+
};
|
|
139
|
+
export const oauthAuthorizeBodySchema = {
|
|
140
|
+
type: 'object',
|
|
141
|
+
required: ['clientId', 'redirectUri'],
|
|
142
|
+
properties: {
|
|
143
|
+
clientId: { type: 'string', minLength: 1 },
|
|
144
|
+
redirectUri: { type: 'string', minLength: 1 },
|
|
145
|
+
scope: {},
|
|
146
|
+
state: { type: 'string' },
|
|
147
|
+
codeChallenge: { type: 'string' },
|
|
148
|
+
codeChallengeMethod: { type: 'string' },
|
|
149
|
+
login: { type: 'string' },
|
|
150
|
+
password: { type: 'string' }
|
|
151
|
+
},
|
|
152
|
+
additionalProperties: false
|
|
153
|
+
};
|
|
154
|
+
export const oauthTokenBodySchema = {
|
|
155
|
+
type: 'object',
|
|
156
|
+
required: ['grant_type'],
|
|
157
|
+
properties: {
|
|
158
|
+
grant_type: { type: 'string', enum: ['authorization_code', 'refresh_token'] },
|
|
159
|
+
code: { type: 'string' },
|
|
160
|
+
redirect_uri: { type: 'string' },
|
|
161
|
+
code_verifier: { type: 'string' },
|
|
162
|
+
client_id: { type: 'string' },
|
|
163
|
+
client_secret: { type: 'string' },
|
|
164
|
+
refresh_token: { type: 'string' },
|
|
165
|
+
scope: { type: 'string' }
|
|
166
|
+
},
|
|
167
|
+
additionalProperties: false
|
|
168
|
+
};
|
|
@@ -8,7 +8,12 @@ export function normalizeComparableUserId(identifier) {
|
|
|
8
8
|
throw new Error(`Unable to normalise user identifier: ${identifier}`);
|
|
9
9
|
}
|
|
10
10
|
if (/^\d+$/.test(trimmed)) {
|
|
11
|
-
|
|
11
|
+
const num = Number(trimmed);
|
|
12
|
+
// Avoid precision loss for large numeric strings
|
|
13
|
+
if (!Number.isSafeInteger(num)) {
|
|
14
|
+
return trimmed;
|
|
15
|
+
}
|
|
16
|
+
return String(num);
|
|
12
17
|
}
|
|
13
18
|
return trimmed;
|
|
14
19
|
}
|
|
@@ -17,9 +22,13 @@ export function normalizeComparableUserId(identifier) {
|
|
|
17
22
|
export function normalizeNumericUserId(identifier) {
|
|
18
23
|
const normalized = normalizeComparableUserId(identifier);
|
|
19
24
|
if (/^\d+$/.test(normalized)) {
|
|
20
|
-
|
|
25
|
+
const num = Number(normalized);
|
|
26
|
+
if (!Number.isSafeInteger(num)) {
|
|
27
|
+
throw new Error(`Sequelize OAuth/Passkey store requires numeric user IDs within safe integer range: ${identifier}`);
|
|
28
|
+
}
|
|
29
|
+
return num;
|
|
21
30
|
}
|
|
22
|
-
throw new Error(`
|
|
31
|
+
throw new Error(`Sequelize OAuth/Passkey store requires numeric user IDs: ${identifier}`);
|
|
23
32
|
}
|
|
24
33
|
export function normalizeStringUserId(identifier) {
|
|
25
34
|
return normalizeComparableUserId(identifier);
|