@technomoron/api-server-base 2.0.0-beta.17 → 2.0.0-beta.19
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 +48 -35
- package/dist/cjs/api-module.cjs +9 -0
- package/dist/cjs/api-module.d.ts +4 -2
- package/dist/cjs/api-server-base.cjs +178 -57
- package/dist/cjs/api-server-base.d.ts +31 -2
- package/dist/cjs/auth-api/auth-module.d.ts +12 -1
- package/dist/cjs/auth-api/auth-module.js +77 -35
- package/dist/cjs/auth-api/mem-auth-store.js +2 -23
- package/dist/cjs/auth-api/sql-auth-store.js +4 -31
- package/dist/cjs/auth-api/user-id.d.ts +4 -0
- package/dist/cjs/auth-api/user-id.js +31 -0
- package/dist/cjs/auth-cookie-options.d.ts +11 -0
- package/dist/cjs/auth-cookie-options.js +57 -0
- package/dist/cjs/oauth/memory.js +4 -10
- package/dist/cjs/oauth/models.js +4 -15
- package/dist/cjs/oauth/sequelize.js +8 -23
- package/dist/cjs/passkey/config.d.ts +2 -0
- package/dist/cjs/passkey/config.js +26 -0
- package/dist/cjs/passkey/memory.js +2 -9
- package/dist/cjs/passkey/models.js +4 -15
- package/dist/cjs/passkey/sequelize.js +6 -22
- package/dist/cjs/passkey/service.js +1 -1
- package/dist/cjs/passkey/types.d.ts +5 -0
- package/dist/cjs/sequelize-utils.d.ts +3 -0
- package/dist/cjs/sequelize-utils.js +17 -0
- package/dist/cjs/token/memory.d.ts +4 -0
- package/dist/cjs/token/memory.js +90 -25
- package/dist/cjs/token/sequelize.js +16 -22
- package/dist/cjs/token/types.d.ts +7 -0
- package/dist/cjs/user/memory.js +2 -9
- package/dist/cjs/user/sequelize.js +6 -22
- package/dist/esm/api-module.d.ts +4 -2
- package/dist/esm/api-module.js +9 -0
- package/dist/esm/api-server-base.d.ts +31 -2
- package/dist/esm/api-server-base.js +178 -57
- package/dist/esm/auth-api/auth-module.d.ts +12 -1
- package/dist/esm/auth-api/auth-module.js +77 -35
- package/dist/esm/auth-api/mem-auth-store.js +1 -22
- package/dist/esm/auth-api/sql-auth-store.js +2 -29
- package/dist/esm/auth-api/user-id.d.ts +4 -0
- package/dist/esm/auth-api/user-id.js +26 -0
- package/dist/esm/auth-cookie-options.d.ts +11 -0
- package/dist/esm/auth-cookie-options.js +54 -0
- package/dist/esm/oauth/memory.js +4 -10
- package/dist/esm/oauth/models.js +1 -12
- package/dist/esm/oauth/sequelize.js +5 -20
- package/dist/esm/passkey/config.d.ts +2 -0
- package/dist/esm/passkey/config.js +23 -0
- package/dist/esm/passkey/memory.js +2 -9
- package/dist/esm/passkey/models.js +1 -12
- package/dist/esm/passkey/sequelize.js +3 -19
- package/dist/esm/passkey/service.js +1 -1
- package/dist/esm/passkey/types.d.ts +5 -0
- package/dist/esm/sequelize-utils.d.ts +3 -0
- package/dist/esm/sequelize-utils.js +12 -0
- package/dist/esm/token/memory.d.ts +4 -0
- package/dist/esm/token/memory.js +90 -25
- package/dist/esm/token/sequelize.js +12 -18
- package/dist/esm/token/types.d.ts +7 -0
- package/dist/esm/user/memory.js +2 -9
- package/dist/esm/user/sequelize.js +3 -19
- package/docs/swagger/openapi.json +11 -145
- package/package.json +12 -12
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { createHash, randomUUID } from 'node:crypto';
|
|
2
2
|
import { isoBase64URL } from '@simplewebauthn/server/helpers';
|
|
3
3
|
import { ApiError } from '../api-server-base.js';
|
|
4
|
+
import { buildAuthCookieOptions } from '../auth-cookie-options.js';
|
|
4
5
|
import { BaseAuthModule } from './module.js';
|
|
5
6
|
import { BaseAuthAdapter } from './storage.js';
|
|
6
7
|
function isAuthIdentifier(value) {
|
|
@@ -30,10 +31,18 @@ function sha256Base64Url(value) {
|
|
|
30
31
|
return base64UrlEncode(hash);
|
|
31
32
|
}
|
|
32
33
|
class AuthModule extends BaseAuthModule {
|
|
34
|
+
get server() {
|
|
35
|
+
return super.server;
|
|
36
|
+
}
|
|
37
|
+
set server(value) {
|
|
38
|
+
super.server = value;
|
|
39
|
+
}
|
|
33
40
|
constructor(options = {}) {
|
|
34
41
|
super({ namespace: options.namespace ?? AuthModule.defaultNamespace });
|
|
35
42
|
this.defaultDomain = options.defaultDomain;
|
|
36
43
|
this.canImpersonateHook = options.canImpersonate;
|
|
44
|
+
this.rateLimitHook = options.rateLimit;
|
|
45
|
+
this.allowInsecurePkcePlain = options.allowInsecurePkcePlain ?? true;
|
|
37
46
|
}
|
|
38
47
|
get storage() {
|
|
39
48
|
return this.server.getAuthStorage();
|
|
@@ -234,29 +243,7 @@ class AuthModule extends BaseAuthModule {
|
|
|
234
243
|
return Number.isNaN(date.getTime()) ? undefined : date.toISOString();
|
|
235
244
|
}
|
|
236
245
|
cookieOptions(apiReq) {
|
|
237
|
-
|
|
238
|
-
const forwarded = apiReq.req.headers['x-forwarded-proto'];
|
|
239
|
-
const referer = apiReq.req.headers['origin'] ?? apiReq.req.headers['referer'];
|
|
240
|
-
const origin = typeof referer === 'string' ? referer : '';
|
|
241
|
-
const isHttps = forwarded === 'https' || apiReq.req.protocol === 'https';
|
|
242
|
-
const isLocalhost = origin.includes('localhost');
|
|
243
|
-
const options = {
|
|
244
|
-
httpOnly: true,
|
|
245
|
-
secure: true,
|
|
246
|
-
sameSite: 'strict',
|
|
247
|
-
domain: conf.cookieDomain || undefined,
|
|
248
|
-
path: '/',
|
|
249
|
-
maxAge: undefined
|
|
250
|
-
};
|
|
251
|
-
if (conf.devMode) {
|
|
252
|
-
options.secure = isHttps;
|
|
253
|
-
options.httpOnly = false;
|
|
254
|
-
options.sameSite = 'lax';
|
|
255
|
-
if (isLocalhost) {
|
|
256
|
-
options.domain = undefined;
|
|
257
|
-
}
|
|
258
|
-
}
|
|
259
|
-
return options;
|
|
246
|
+
return buildAuthCookieOptions(this.server.config, apiReq.req);
|
|
260
247
|
}
|
|
261
248
|
setJwtCookies(apiReq, tokens, preferences = {}) {
|
|
262
249
|
const conf = this.server.config;
|
|
@@ -283,7 +270,10 @@ class AuthModule extends BaseAuthModule {
|
|
|
283
270
|
async issueTokens(apiReq, user, metadata = {}) {
|
|
284
271
|
const conf = this.server.config;
|
|
285
272
|
const enrichedMetadata = this.enrichTokenMetadata(apiReq, metadata);
|
|
286
|
-
const payload =
|
|
273
|
+
const payload = {
|
|
274
|
+
...this.buildTokenPayload(user, enrichedMetadata),
|
|
275
|
+
jti: randomUUID()
|
|
276
|
+
};
|
|
287
277
|
const access = this.server.jwtSign(payload, conf.accessSecret, conf.accessExpiry);
|
|
288
278
|
if (!access.success || !access.token) {
|
|
289
279
|
throw new ApiError({ code: 500, message: access.error ?? 'Unable to sign access token' });
|
|
@@ -453,6 +443,7 @@ class AuthModule extends BaseAuthModule {
|
|
|
453
443
|
return undefined;
|
|
454
444
|
}
|
|
455
445
|
async postLogin(apiReq) {
|
|
446
|
+
await this.applyRateLimit(apiReq, 'login');
|
|
456
447
|
this.assertAuthReady();
|
|
457
448
|
const { login, password, ...metadata } = this.parseLoginBody(apiReq);
|
|
458
449
|
const user = await this.storage.getUser(login);
|
|
@@ -550,10 +541,39 @@ class AuthModule extends BaseAuthModule {
|
|
|
550
541
|
apiReq.req.cookies[conf.accessCookie].trim().length > 0);
|
|
551
542
|
const shouldRefresh = Boolean(body.refresh) || !hasAccessToken;
|
|
552
543
|
if (shouldRefresh) {
|
|
553
|
-
const
|
|
544
|
+
const updateToken = this.storage.updateToken;
|
|
545
|
+
if (typeof updateToken !== 'function' || !this.storageImplements('updateToken')) {
|
|
546
|
+
throw new ApiError({ code: 501, message: 'Token update storage is not configured' });
|
|
547
|
+
}
|
|
548
|
+
// Sign a new access token without embedding stored token secrets into the JWT payload.
|
|
549
|
+
const metadata = {
|
|
550
|
+
ruid: stored.ruid,
|
|
551
|
+
domain: stored.domain,
|
|
552
|
+
fingerprint: stored.fingerprint,
|
|
553
|
+
label: stored.label,
|
|
554
|
+
clientId: stored.clientId,
|
|
555
|
+
scope: stored.scope,
|
|
556
|
+
browser: stored.browser,
|
|
557
|
+
device: stored.device,
|
|
558
|
+
ip: stored.ip,
|
|
559
|
+
os: stored.os,
|
|
560
|
+
loginType: stored.loginType,
|
|
561
|
+
refreshTtlSeconds: this.normalizeRefreshTtlSeconds(stored.refreshTtlSeconds),
|
|
562
|
+
sessionCookie: stored.sessionCookie
|
|
563
|
+
};
|
|
564
|
+
const enrichedMetadata = this.enrichTokenMetadata(apiReq, metadata);
|
|
565
|
+
const access = this.server.jwtSign(this.buildTokenPayload(user, enrichedMetadata), conf.accessSecret, conf.accessExpiry);
|
|
554
566
|
if (!access.success || !access.token) {
|
|
555
567
|
throw new ApiError({ code: 500, message: access.error ?? 'Unable to sign access token' });
|
|
556
568
|
}
|
|
569
|
+
const updated = await updateToken.call(this.storage, {
|
|
570
|
+
refreshToken,
|
|
571
|
+
accessToken: access.token,
|
|
572
|
+
lastSeenAt: new Date()
|
|
573
|
+
});
|
|
574
|
+
if (!updated) {
|
|
575
|
+
throw new ApiError({ code: 500, message: 'Unable to persist refreshed access token' });
|
|
576
|
+
}
|
|
557
577
|
const cookiePrefs = this.mergeSessionPreferences({
|
|
558
578
|
sessionCookie: stored.sessionCookie,
|
|
559
579
|
refreshTtlSeconds: this.normalizeRefreshTtlSeconds(stored.refreshTtlSeconds)
|
|
@@ -587,6 +607,7 @@ class AuthModule extends BaseAuthModule {
|
|
|
587
607
|
];
|
|
588
608
|
}
|
|
589
609
|
async postPasskeyChallenge(apiReq) {
|
|
610
|
+
await this.applyRateLimit(apiReq, 'passkey-challenge');
|
|
590
611
|
if (typeof this.storage.createPasskeyChallenge !== 'function') {
|
|
591
612
|
throw new ApiError({ code: 501, message: 'Passkey support is not configured' });
|
|
592
613
|
}
|
|
@@ -724,7 +745,8 @@ class AuthModule extends BaseAuthModule {
|
|
|
724
745
|
async deleteImpersonation(apiReq) {
|
|
725
746
|
this.assertAuthReady();
|
|
726
747
|
const actor = await this.resolveActorContext(apiReq);
|
|
727
|
-
const
|
|
748
|
+
const query = (apiReq.req.query ?? {});
|
|
749
|
+
const metadata = this.buildImpersonationMetadata(query);
|
|
728
750
|
metadata.loginType = metadata.loginType ?? 'impersonation-end';
|
|
729
751
|
const tokens = await this.issueTokens(apiReq, actor.user, metadata);
|
|
730
752
|
const publicUser = this.storage.filterUser(actor.user);
|
|
@@ -796,6 +818,7 @@ class AuthModule extends BaseAuthModule {
|
|
|
796
818
|
const state = toStringOrNull(body.state) ?? undefined;
|
|
797
819
|
const codeChallenge = toStringOrNull(body.codeChallenge) ?? undefined;
|
|
798
820
|
const codeChallengeMethod = toStringOrNull(body.codeChallengeMethod) ?? undefined;
|
|
821
|
+
const resolvedCodeChallengeMethod = this.resolvePkceChallengeMethod(codeChallengeMethod);
|
|
799
822
|
if (!clientId) {
|
|
800
823
|
throw new ApiError({ code: 400, message: 'clientId is required' });
|
|
801
824
|
}
|
|
@@ -815,7 +838,7 @@ class AuthModule extends BaseAuthModule {
|
|
|
815
838
|
redirectUri,
|
|
816
839
|
scope: resolvedScope,
|
|
817
840
|
codeChallenge,
|
|
818
|
-
codeChallengeMethod:
|
|
841
|
+
codeChallengeMethod: resolvedCodeChallengeMethod,
|
|
819
842
|
expiresInSeconds: 300
|
|
820
843
|
});
|
|
821
844
|
const redirect = new URL(redirectUri);
|
|
@@ -826,6 +849,7 @@ class AuthModule extends BaseAuthModule {
|
|
|
826
849
|
return [200, { code: codeRecord.code, redirectUri: redirect.toString(), state }];
|
|
827
850
|
}
|
|
828
851
|
async postOAuthToken(apiReq) {
|
|
852
|
+
await this.applyRateLimit(apiReq, 'oauth-token');
|
|
829
853
|
if (typeof this.storage.getClient !== 'function' || typeof this.storage.consumeAuthCode !== 'function') {
|
|
830
854
|
throw new ApiError({ code: 501, message: 'OAuth token storage is not configured' });
|
|
831
855
|
}
|
|
@@ -879,6 +903,9 @@ class AuthModule extends BaseAuthModule {
|
|
|
879
903
|
}
|
|
880
904
|
}
|
|
881
905
|
else if (record.codeChallengeMethod === 'plain') {
|
|
906
|
+
if (!this.allowInsecurePkcePlain) {
|
|
907
|
+
throw new ApiError({ code: 400, message: 'PKCE plain is not permitted' });
|
|
908
|
+
}
|
|
882
909
|
if (codeVerifier !== record.codeChallenge) {
|
|
883
910
|
throw new ApiError({ code: 400, message: 'code_verifier does not match challenge' });
|
|
884
911
|
}
|
|
@@ -999,14 +1026,11 @@ class AuthModule extends BaseAuthModule {
|
|
|
999
1026
|
if (!secretProvided) {
|
|
1000
1027
|
throw new ApiError({ code: 400, message: 'Client authentication is required' });
|
|
1001
1028
|
}
|
|
1002
|
-
|
|
1003
|
-
if (this.
|
|
1004
|
-
|
|
1005
|
-
valid = await verifySecret(client, clientSecret);
|
|
1006
|
-
}
|
|
1007
|
-
else {
|
|
1008
|
-
valid = client.clientSecret === clientSecret;
|
|
1029
|
+
const verifySecret = this.storage.verifyClientSecret;
|
|
1030
|
+
if (typeof verifySecret !== 'function' || !this.storageImplements('verifyClientSecret')) {
|
|
1031
|
+
throw new ApiError({ code: 501, message: 'OAuth client secret verification is not configured' });
|
|
1009
1032
|
}
|
|
1033
|
+
const valid = await verifySecret.call(this.storage, client, clientSecret);
|
|
1010
1034
|
if (!valid) {
|
|
1011
1035
|
throw new ApiError({ code: 401, message: 'Invalid client credentials' });
|
|
1012
1036
|
}
|
|
@@ -1082,6 +1106,24 @@ class AuthModule extends BaseAuthModule {
|
|
|
1082
1106
|
storageImplementsAll(keys) {
|
|
1083
1107
|
return keys.every((key) => this.storageImplements(key));
|
|
1084
1108
|
}
|
|
1109
|
+
async applyRateLimit(apiReq, endpoint) {
|
|
1110
|
+
if (!this.rateLimitHook) {
|
|
1111
|
+
return;
|
|
1112
|
+
}
|
|
1113
|
+
await this.rateLimitHook({ apiReq, endpoint });
|
|
1114
|
+
}
|
|
1115
|
+
resolvePkceChallengeMethod(value) {
|
|
1116
|
+
if (value === 'S256') {
|
|
1117
|
+
return 'S256';
|
|
1118
|
+
}
|
|
1119
|
+
if (value === 'plain') {
|
|
1120
|
+
if (!this.allowInsecurePkcePlain) {
|
|
1121
|
+
throw new ApiError({ code: 400, message: 'PKCE plain is not permitted' });
|
|
1122
|
+
}
|
|
1123
|
+
return 'plain';
|
|
1124
|
+
}
|
|
1125
|
+
return undefined;
|
|
1126
|
+
}
|
|
1085
1127
|
defineRoutes() {
|
|
1086
1128
|
const routes = [];
|
|
1087
1129
|
const coreAuthSupported = this.storageImplementsAll([
|
|
@@ -1154,7 +1196,7 @@ class AuthModule extends BaseAuthModule {
|
|
|
1154
1196
|
auth: { type: 'strict', req: 'any' }
|
|
1155
1197
|
}, {
|
|
1156
1198
|
method: 'delete',
|
|
1157
|
-
path: '/v1/passkeys/:credentialId
|
|
1199
|
+
path: '/v1/passkeys/:credentialId',
|
|
1158
1200
|
handler: (req) => this.deletePasskey(req),
|
|
1159
1201
|
auth: { type: 'strict', req: 'any' }
|
|
1160
1202
|
});
|
|
@@ -1,30 +1,9 @@
|
|
|
1
1
|
import { MemoryOAuthStore } from '../oauth/memory.js';
|
|
2
|
+
import { normalizePasskeyConfig } from '../passkey/config.js';
|
|
2
3
|
import { MemoryPasskeyStore } from '../passkey/memory.js';
|
|
3
4
|
import { MemoryTokenStore } from '../token/memory.js';
|
|
4
5
|
import { MemoryUserStore } from '../user/memory.js';
|
|
5
6
|
import { CompositeAuthAdapter } from './compat-auth-storage.js';
|
|
6
|
-
const DEFAULT_PASSKEY_CONFIG = {
|
|
7
|
-
rpId: 'localhost',
|
|
8
|
-
rpName: 'API Server',
|
|
9
|
-
origins: ['http://localhost:5173'],
|
|
10
|
-
timeoutMs: 5 * 60 * 1000,
|
|
11
|
-
userVerification: 'preferred'
|
|
12
|
-
};
|
|
13
|
-
function isOriginString(origin) {
|
|
14
|
-
return typeof origin === 'string' && origin.trim().length > 0;
|
|
15
|
-
}
|
|
16
|
-
function normalizePasskeyConfig(config = {}) {
|
|
17
|
-
const candidateOrigins = Array.isArray(config.origins) && config.origins.length > 0 ? config.origins.filter(isOriginString) : null;
|
|
18
|
-
return {
|
|
19
|
-
rpId: config.rpId?.trim() || DEFAULT_PASSKEY_CONFIG.rpId,
|
|
20
|
-
rpName: config.rpName?.trim() || DEFAULT_PASSKEY_CONFIG.rpName,
|
|
21
|
-
origins: candidateOrigins ? candidateOrigins.map((origin) => origin.trim()) : DEFAULT_PASSKEY_CONFIG.origins,
|
|
22
|
-
timeoutMs: typeof config.timeoutMs === 'number' && config.timeoutMs > 0
|
|
23
|
-
? config.timeoutMs
|
|
24
|
-
: DEFAULT_PASSKEY_CONFIG.timeoutMs,
|
|
25
|
-
userVerification: config.userVerification ?? DEFAULT_PASSKEY_CONFIG.userVerification
|
|
26
|
-
};
|
|
27
|
-
}
|
|
28
7
|
export class MemAuthStore {
|
|
29
8
|
constructor(params = {}) {
|
|
30
9
|
this.userStore = new MemoryUserStore({
|
|
@@ -1,22 +1,10 @@
|
|
|
1
1
|
import { SequelizeOAuthStore } from '../oauth/sequelize.js';
|
|
2
|
+
import { normalizePasskeyConfig } from '../passkey/config.js';
|
|
2
3
|
import { SequelizePasskeyStore } from '../passkey/sequelize.js';
|
|
4
|
+
import { normalizeTablePrefix } from '../sequelize-utils.js';
|
|
3
5
|
import { SequelizeTokenStore } from '../token/sequelize.js';
|
|
4
6
|
import { SequelizeUserStore } from '../user/sequelize.js';
|
|
5
7
|
import { CompositeAuthAdapter } from './compat-auth-storage.js';
|
|
6
|
-
const DEFAULT_PASSKEY_CONFIG = {
|
|
7
|
-
rpId: 'localhost',
|
|
8
|
-
rpName: 'API Server',
|
|
9
|
-
origins: ['http://localhost:5173'],
|
|
10
|
-
timeoutMs: 5 * 60 * 1000,
|
|
11
|
-
userVerification: 'preferred'
|
|
12
|
-
};
|
|
13
|
-
function normalizeTablePrefix(prefix) {
|
|
14
|
-
if (!prefix) {
|
|
15
|
-
return undefined;
|
|
16
|
-
}
|
|
17
|
-
const trimmed = prefix.trim();
|
|
18
|
-
return trimmed.length > 0 ? trimmed : undefined;
|
|
19
|
-
}
|
|
20
8
|
function resolveTablePrefix(...prefixes) {
|
|
21
9
|
for (const prefix of prefixes) {
|
|
22
10
|
const normalized = normalizeTablePrefix(prefix);
|
|
@@ -26,21 +14,6 @@ function resolveTablePrefix(...prefixes) {
|
|
|
26
14
|
}
|
|
27
15
|
return undefined;
|
|
28
16
|
}
|
|
29
|
-
function isOriginString(origin) {
|
|
30
|
-
return typeof origin === 'string' && origin.trim().length > 0;
|
|
31
|
-
}
|
|
32
|
-
function normalizePasskeyConfig(config = {}) {
|
|
33
|
-
const candidateOrigins = Array.isArray(config.origins) && config.origins.length > 0 ? config.origins.filter(isOriginString) : null;
|
|
34
|
-
return {
|
|
35
|
-
rpId: config.rpId?.trim() || DEFAULT_PASSKEY_CONFIG.rpId,
|
|
36
|
-
rpName: config.rpName?.trim() || DEFAULT_PASSKEY_CONFIG.rpName,
|
|
37
|
-
origins: candidateOrigins ? candidateOrigins.map((origin) => origin.trim()) : DEFAULT_PASSKEY_CONFIG.origins,
|
|
38
|
-
timeoutMs: typeof config.timeoutMs === 'number' && config.timeoutMs > 0
|
|
39
|
-
? config.timeoutMs
|
|
40
|
-
: DEFAULT_PASSKEY_CONFIG.timeoutMs,
|
|
41
|
-
userVerification: config.userVerification ?? DEFAULT_PASSKEY_CONFIG.userVerification
|
|
42
|
-
};
|
|
43
|
-
}
|
|
44
17
|
export class SqlAuthStore {
|
|
45
18
|
constructor(params) {
|
|
46
19
|
this.closed = false;
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import type { AuthIdentifier } from './types.js';
|
|
2
|
+
export declare function normalizeComparableUserId(identifier: AuthIdentifier): string;
|
|
3
|
+
export declare function normalizeNumericUserId(identifier: AuthIdentifier): number;
|
|
4
|
+
export declare function normalizeStringUserId(identifier: AuthIdentifier): string;
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
export function normalizeComparableUserId(identifier) {
|
|
2
|
+
if (typeof identifier === 'number' && Number.isFinite(identifier)) {
|
|
3
|
+
return String(identifier);
|
|
4
|
+
}
|
|
5
|
+
if (typeof identifier === 'string') {
|
|
6
|
+
const trimmed = identifier.trim();
|
|
7
|
+
if (trimmed.length === 0) {
|
|
8
|
+
throw new Error(`Unable to normalise user identifier: ${identifier}`);
|
|
9
|
+
}
|
|
10
|
+
if (/^\d+$/.test(trimmed)) {
|
|
11
|
+
return String(Number(trimmed));
|
|
12
|
+
}
|
|
13
|
+
return trimmed;
|
|
14
|
+
}
|
|
15
|
+
throw new Error(`Unable to normalise user identifier: ${identifier}`);
|
|
16
|
+
}
|
|
17
|
+
export function normalizeNumericUserId(identifier) {
|
|
18
|
+
const normalized = normalizeComparableUserId(identifier);
|
|
19
|
+
if (/^\d+$/.test(normalized)) {
|
|
20
|
+
return Number(normalized);
|
|
21
|
+
}
|
|
22
|
+
throw new Error(`Unable to normalise user identifier: ${identifier}`);
|
|
23
|
+
}
|
|
24
|
+
export function normalizeStringUserId(identifier) {
|
|
25
|
+
return normalizeComparableUserId(identifier);
|
|
26
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { Request } from 'express';
|
|
2
|
+
import type { CookieOptions } from 'express-serve-static-core';
|
|
3
|
+
export interface AuthCookieConfig {
|
|
4
|
+
cookieSecure?: boolean | 'auto';
|
|
5
|
+
cookieSameSite?: 'lax' | 'strict' | 'none';
|
|
6
|
+
cookieHttpOnly?: boolean;
|
|
7
|
+
cookieDomain?: string;
|
|
8
|
+
cookiePath?: string;
|
|
9
|
+
devMode?: boolean;
|
|
10
|
+
}
|
|
11
|
+
export declare function buildAuthCookieOptions(config: AuthCookieConfig, req: Pick<Request, 'headers' | 'protocol'>): CookieOptions;
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
function firstHeaderValue(value) {
|
|
2
|
+
if (typeof value === 'string') {
|
|
3
|
+
return value;
|
|
4
|
+
}
|
|
5
|
+
if (Array.isArray(value)) {
|
|
6
|
+
return value[0] ?? '';
|
|
7
|
+
}
|
|
8
|
+
return '';
|
|
9
|
+
}
|
|
10
|
+
function resolveOriginHostname(origin) {
|
|
11
|
+
try {
|
|
12
|
+
const url = new URL(origin);
|
|
13
|
+
const hostname = url.hostname.trim().toLowerCase();
|
|
14
|
+
return hostname.length > 0 ? hostname : null;
|
|
15
|
+
}
|
|
16
|
+
catch {
|
|
17
|
+
return null;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
function isLocalhostOrigin(origin) {
|
|
21
|
+
const hostname = resolveOriginHostname(origin);
|
|
22
|
+
if (!hostname) {
|
|
23
|
+
return false;
|
|
24
|
+
}
|
|
25
|
+
return hostname === 'localhost' || hostname.endsWith('.localhost');
|
|
26
|
+
}
|
|
27
|
+
export function buildAuthCookieOptions(config, req) {
|
|
28
|
+
const forwardedProto = firstHeaderValue(req.headers['x-forwarded-proto']).split(',')[0].trim().toLowerCase();
|
|
29
|
+
const isHttps = forwardedProto === 'https' || req.protocol === 'https';
|
|
30
|
+
const origin = firstHeaderValue(req.headers.origin ?? req.headers.referer);
|
|
31
|
+
const secure = config.cookieSecure === true ? true : config.cookieSecure === false ? false : /* auto */ Boolean(isHttps);
|
|
32
|
+
let sameSite = config.cookieSameSite ?? 'lax';
|
|
33
|
+
if (sameSite !== 'lax' && sameSite !== 'strict' && sameSite !== 'none') {
|
|
34
|
+
sameSite = 'lax';
|
|
35
|
+
}
|
|
36
|
+
let resolvedSecure = secure;
|
|
37
|
+
if (sameSite === 'none' && resolvedSecure !== true) {
|
|
38
|
+
// Modern browsers reject SameSite=None cookies unless Secure is set.
|
|
39
|
+
resolvedSecure = true;
|
|
40
|
+
}
|
|
41
|
+
const options = {
|
|
42
|
+
httpOnly: config.cookieHttpOnly ?? true,
|
|
43
|
+
secure: resolvedSecure,
|
|
44
|
+
sameSite,
|
|
45
|
+
domain: config.cookieDomain || undefined,
|
|
46
|
+
path: config.cookiePath || '/',
|
|
47
|
+
maxAge: undefined
|
|
48
|
+
};
|
|
49
|
+
if (config.devMode && isLocalhostOrigin(origin)) {
|
|
50
|
+
// Domain cookies do not work on localhost; avoid breaking local development when cookieDomain is set.
|
|
51
|
+
options.domain = undefined;
|
|
52
|
+
}
|
|
53
|
+
return options;
|
|
54
|
+
}
|
package/dist/esm/oauth/memory.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import bcrypt from 'bcryptjs';
|
|
2
|
+
import { normalizeNumericUserId } from '../auth-api/user-id.js';
|
|
2
3
|
import { OAuthStore } from './base.js';
|
|
3
4
|
function cloneClient(client) {
|
|
4
5
|
if (!client) {
|
|
@@ -6,7 +7,8 @@ function cloneClient(client) {
|
|
|
6
7
|
}
|
|
7
8
|
return {
|
|
8
9
|
clientId: client.clientId,
|
|
9
|
-
clientSecret
|
|
10
|
+
// clientSecret is stored hashed; do not return the hash.
|
|
11
|
+
clientSecret: client.clientSecret ? '__stored__' : undefined,
|
|
10
12
|
name: client.name,
|
|
11
13
|
redirectUris: [...client.redirectUris],
|
|
12
14
|
scope: client.scope ? [...client.scope] : undefined,
|
|
@@ -22,15 +24,7 @@ function cloneCode(code) {
|
|
|
22
24
|
metadata: code.metadata ? { ...code.metadata } : undefined
|
|
23
25
|
};
|
|
24
26
|
}
|
|
25
|
-
|
|
26
|
-
if (typeof identifier === 'number' && Number.isFinite(identifier)) {
|
|
27
|
-
return identifier;
|
|
28
|
-
}
|
|
29
|
-
if (typeof identifier === 'string' && /^\d+$/.test(identifier)) {
|
|
30
|
-
return Number(identifier);
|
|
31
|
-
}
|
|
32
|
-
throw new Error(`Unable to normalise user identifier: ${identifier}`);
|
|
33
|
-
}
|
|
27
|
+
const normalizeUserId = normalizeNumericUserId;
|
|
34
28
|
export class MemoryOAuthStore extends OAuthStore {
|
|
35
29
|
constructor(options = {}) {
|
|
36
30
|
super();
|
package/dist/esm/oauth/models.js
CHANGED
|
@@ -1,16 +1,5 @@
|
|
|
1
1
|
import { DataTypes, Model } from 'sequelize';
|
|
2
|
-
|
|
3
|
-
function normalizeTablePrefix(prefix) {
|
|
4
|
-
if (!prefix) {
|
|
5
|
-
return undefined;
|
|
6
|
-
}
|
|
7
|
-
const trimmed = prefix.trim();
|
|
8
|
-
return trimmed.length > 0 ? trimmed : undefined;
|
|
9
|
-
}
|
|
10
|
-
function applyTablePrefix(prefix, tableName) {
|
|
11
|
-
const normalized = normalizeTablePrefix(prefix);
|
|
12
|
-
return normalized ? `${normalized}${tableName}` : tableName;
|
|
13
|
-
}
|
|
2
|
+
import { DIALECTS_SUPPORTING_UNSIGNED, applyTablePrefix } from '../sequelize-utils.js';
|
|
14
3
|
function integerIdType(sequelize) {
|
|
15
4
|
return DIALECTS_SUPPORTING_UNSIGNED.has(sequelize.getDialect()) ? DataTypes.INTEGER.UNSIGNED : DataTypes.INTEGER;
|
|
16
5
|
}
|
|
@@ -1,18 +1,8 @@
|
|
|
1
1
|
import bcrypt from 'bcryptjs';
|
|
2
2
|
import { DataTypes, Model } from 'sequelize';
|
|
3
|
+
import { normalizeNumericUserId } from '../auth-api/user-id.js';
|
|
4
|
+
import { DIALECTS_SUPPORTING_UNSIGNED, applyTablePrefix } from '../sequelize-utils.js';
|
|
3
5
|
import { OAuthStore } from './base.js';
|
|
4
|
-
const DIALECTS_SUPPORTING_UNSIGNED = new Set(['mysql', 'mariadb']);
|
|
5
|
-
function normalizeTablePrefix(prefix) {
|
|
6
|
-
if (!prefix) {
|
|
7
|
-
return undefined;
|
|
8
|
-
}
|
|
9
|
-
const trimmed = prefix.trim();
|
|
10
|
-
return trimmed.length > 0 ? trimmed : undefined;
|
|
11
|
-
}
|
|
12
|
-
function applyTablePrefix(prefix, tableName) {
|
|
13
|
-
const normalized = normalizeTablePrefix(prefix);
|
|
14
|
-
return normalized ? `${normalized}${tableName}` : tableName;
|
|
15
|
-
}
|
|
16
6
|
function integerIdType(sequelize) {
|
|
17
7
|
return DIALECTS_SUPPORTING_UNSIGNED.has(sequelize.getDialect()) ? DataTypes.INTEGER.UNSIGNED : DataTypes.INTEGER;
|
|
18
8
|
}
|
|
@@ -101,13 +91,7 @@ function parseMetadata(raw) {
|
|
|
101
91
|
return undefined;
|
|
102
92
|
}
|
|
103
93
|
function normalizeUserId(identifier) {
|
|
104
|
-
|
|
105
|
-
return identifier;
|
|
106
|
-
}
|
|
107
|
-
if (typeof identifier === 'string' && /^\d+$/.test(identifier)) {
|
|
108
|
-
return Number(identifier);
|
|
109
|
-
}
|
|
110
|
-
throw new Error(`Unable to normalise user identifier: ${identifier}`);
|
|
94
|
+
return normalizeNumericUserId(identifier);
|
|
111
95
|
}
|
|
112
96
|
export class SequelizeOAuthStore extends OAuthStore {
|
|
113
97
|
constructor(options) {
|
|
@@ -194,7 +178,8 @@ export class SequelizeOAuthStore extends OAuthStore {
|
|
|
194
178
|
toOAuthClient(model) {
|
|
195
179
|
return {
|
|
196
180
|
clientId: model.client_id,
|
|
197
|
-
|
|
181
|
+
// client_secret is stored hashed; do not return the hash.
|
|
182
|
+
clientSecret: model.client_secret ? '__stored__' : undefined,
|
|
198
183
|
name: model.name ?? undefined,
|
|
199
184
|
redirectUris: decodeStringArray(model.redirect_uris),
|
|
200
185
|
scope: decodeStringArray(model.scope),
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
const DEFAULT_PASSKEY_CONFIG = {
|
|
2
|
+
rpId: 'localhost',
|
|
3
|
+
rpName: 'API Server',
|
|
4
|
+
origins: ['http://localhost:5173'],
|
|
5
|
+
timeoutMs: 5 * 60 * 1000,
|
|
6
|
+
userVerification: 'preferred'
|
|
7
|
+
};
|
|
8
|
+
function isOriginString(origin) {
|
|
9
|
+
return typeof origin === 'string' && origin.trim().length > 0;
|
|
10
|
+
}
|
|
11
|
+
export function normalizePasskeyConfig(config = {}) {
|
|
12
|
+
const candidateOrigins = Array.isArray(config.origins) && config.origins.length > 0 ? config.origins.filter(isOriginString) : null;
|
|
13
|
+
return {
|
|
14
|
+
rpId: config.rpId?.trim() || DEFAULT_PASSKEY_CONFIG.rpId,
|
|
15
|
+
rpName: config.rpName?.trim() || DEFAULT_PASSKEY_CONFIG.rpName,
|
|
16
|
+
origins: candidateOrigins ? candidateOrigins.map((origin) => origin.trim()) : DEFAULT_PASSKEY_CONFIG.origins,
|
|
17
|
+
timeoutMs: typeof config.timeoutMs === 'number' && config.timeoutMs > 0
|
|
18
|
+
? config.timeoutMs
|
|
19
|
+
: DEFAULT_PASSKEY_CONFIG.timeoutMs,
|
|
20
|
+
userVerification: config.userVerification ?? DEFAULT_PASSKEY_CONFIG.userVerification,
|
|
21
|
+
debug: Boolean(config.debug)
|
|
22
|
+
};
|
|
23
|
+
}
|
|
@@ -1,16 +1,9 @@
|
|
|
1
|
+
import { normalizeComparableUserId } from '../auth-api/user-id.js';
|
|
1
2
|
import { PasskeyStore } from './base.js';
|
|
2
3
|
function encodeCredentialId(value) {
|
|
3
4
|
return Buffer.isBuffer(value) ? value.toString('base64') : value;
|
|
4
5
|
}
|
|
5
|
-
|
|
6
|
-
if (typeof identifier === 'number' && Number.isFinite(identifier)) {
|
|
7
|
-
return identifier;
|
|
8
|
-
}
|
|
9
|
-
if (typeof identifier === 'string' && /^\d+$/.test(identifier)) {
|
|
10
|
-
return Number(identifier);
|
|
11
|
-
}
|
|
12
|
-
return identifier;
|
|
13
|
-
}
|
|
6
|
+
const normalizeUserId = normalizeComparableUserId;
|
|
14
7
|
function cloneCredential(record) {
|
|
15
8
|
return {
|
|
16
9
|
...record,
|
|
@@ -1,16 +1,5 @@
|
|
|
1
1
|
import { DataTypes, Model } from 'sequelize';
|
|
2
|
-
|
|
3
|
-
function normalizeTablePrefix(prefix) {
|
|
4
|
-
if (!prefix) {
|
|
5
|
-
return undefined;
|
|
6
|
-
}
|
|
7
|
-
const trimmed = prefix.trim();
|
|
8
|
-
return trimmed.length > 0 ? trimmed : undefined;
|
|
9
|
-
}
|
|
10
|
-
function applyTablePrefix(prefix, tableName) {
|
|
11
|
-
const normalized = normalizeTablePrefix(prefix);
|
|
12
|
-
return normalized ? `${normalized}${tableName}` : tableName;
|
|
13
|
-
}
|
|
2
|
+
import { DIALECTS_SUPPORTING_UNSIGNED, applyTablePrefix } from '../sequelize-utils.js';
|
|
14
3
|
function integerIdType(sequelize) {
|
|
15
4
|
return DIALECTS_SUPPORTING_UNSIGNED.has(sequelize.getDialect()) ? DataTypes.INTEGER.UNSIGNED : DataTypes.INTEGER;
|
|
16
5
|
}
|
|
@@ -1,17 +1,7 @@
|
|
|
1
1
|
import { DataTypes, Model, Op } from 'sequelize';
|
|
2
|
+
import { normalizeNumericUserId } from '../auth-api/user-id.js';
|
|
3
|
+
import { DIALECTS_SUPPORTING_UNSIGNED, applyTablePrefix } from '../sequelize-utils.js';
|
|
2
4
|
import { PasskeyStore } from './base.js';
|
|
3
|
-
const DIALECTS_SUPPORTING_UNSIGNED = new Set(['mysql', 'mariadb']);
|
|
4
|
-
function normalizeTablePrefix(prefix) {
|
|
5
|
-
if (!prefix) {
|
|
6
|
-
return undefined;
|
|
7
|
-
}
|
|
8
|
-
const trimmed = prefix.trim();
|
|
9
|
-
return trimmed.length > 0 ? trimmed : undefined;
|
|
10
|
-
}
|
|
11
|
-
function applyTablePrefix(prefix, tableName) {
|
|
12
|
-
const normalized = normalizeTablePrefix(prefix);
|
|
13
|
-
return normalized ? `${normalized}${tableName}` : tableName;
|
|
14
|
-
}
|
|
15
5
|
function integerIdType(sequelize) {
|
|
16
6
|
return DIALECTS_SUPPORTING_UNSIGNED.has(sequelize.getDialect()) ? DataTypes.INTEGER.UNSIGNED : DataTypes.INTEGER;
|
|
17
7
|
}
|
|
@@ -19,13 +9,7 @@ function encodeCredentialId(value) {
|
|
|
19
9
|
return Buffer.isBuffer(value) ? value.toString('base64') : value;
|
|
20
10
|
}
|
|
21
11
|
function normalizeUserId(identifier) {
|
|
22
|
-
|
|
23
|
-
return identifier;
|
|
24
|
-
}
|
|
25
|
-
if (typeof identifier === 'string' && /^\d+$/.test(identifier)) {
|
|
26
|
-
return Number(identifier);
|
|
27
|
-
}
|
|
28
|
-
throw new Error(`Unable to normalise user identifier: ${identifier}`);
|
|
12
|
+
return normalizeNumericUserId(identifier);
|
|
29
13
|
}
|
|
30
14
|
class PasskeyCredentialModel extends Model {
|
|
31
15
|
}
|
|
@@ -232,7 +232,7 @@ export class PasskeyService {
|
|
|
232
232
|
}
|
|
233
233
|
}
|
|
234
234
|
const publicKey = publicKeyPrimary && publicKeyPrimary.length > 0 ? publicKeyPrimary : publicKeyFallback;
|
|
235
|
-
if (this.logger?.warn) {
|
|
235
|
+
if (this.config.debug && this.logger?.warn) {
|
|
236
236
|
const pkPrimaryHex = publicKeyPrimary ? publicKeyPrimary.slice(0, 4).toString('hex') : 'null';
|
|
237
237
|
const pkFallbackHex = publicKeyFallback ? publicKeyFallback.slice(0, 4).toString('hex') : 'null';
|
|
238
238
|
this.logger.warn(`Passkey registration: pkPrimary len=${publicKeyPrimary?.length ?? 0} head=${pkPrimaryHex}, pkFallback len=${publicKeyFallback?.length ?? 0} head=${pkFallbackHex}`);
|
|
@@ -8,6 +8,11 @@ export interface PasskeyServiceConfig {
|
|
|
8
8
|
origins: string[];
|
|
9
9
|
timeoutMs: number;
|
|
10
10
|
userVerification?: 'preferred' | 'required' | 'discouraged';
|
|
11
|
+
/**
|
|
12
|
+
* When enabled, PasskeyService emits additional diagnostic logs during registration/authentication.
|
|
13
|
+
* Defaults to false.
|
|
14
|
+
*/
|
|
15
|
+
debug?: boolean;
|
|
11
16
|
}
|
|
12
17
|
export interface PasskeyChallengeRecord {
|
|
13
18
|
challenge: string;
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export const DIALECTS_SUPPORTING_UNSIGNED = new Set(['mysql', 'mariadb']);
|
|
2
|
+
export function normalizeTablePrefix(prefix) {
|
|
3
|
+
if (!prefix) {
|
|
4
|
+
return undefined;
|
|
5
|
+
}
|
|
6
|
+
const trimmed = prefix.trim();
|
|
7
|
+
return trimmed.length > 0 ? trimmed : undefined;
|
|
8
|
+
}
|
|
9
|
+
export function applyTablePrefix(prefix, tableName) {
|
|
10
|
+
const normalized = normalizeTablePrefix(prefix);
|
|
11
|
+
return normalized ? `${normalized}${tableName}` : tableName;
|
|
12
|
+
}
|
|
@@ -2,6 +2,10 @@ import { TokenStore } from './base.js';
|
|
|
2
2
|
import type { Token } from './types.js';
|
|
3
3
|
export declare class MemoryTokenStore extends TokenStore {
|
|
4
4
|
private readonly tokens;
|
|
5
|
+
private readonly tokensByUser;
|
|
6
|
+
private indexToken;
|
|
7
|
+
private unindexToken;
|
|
8
|
+
private removeByRefreshToken;
|
|
5
9
|
save(record: Token): Promise<void>;
|
|
6
10
|
get(query: Partial<Token>, opts?: {
|
|
7
11
|
includeExpired?: boolean;
|