@absolutejs/auth 0.75.3 → 0.76.0
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/cli/migrate.js +13 -2
- package/dist/cli/migrate.js.map +4 -4
- package/dist/index.d.ts +80 -4
- package/dist/index.js +290 -57
- package/dist/index.js.map +12 -12
- package/dist/mfa/challenge.d.ts +53 -7
- package/dist/mfa/management.d.ts +49 -6
- package/dist/mfa/postgresMfaStore.d.ts +31 -1
- package/dist/mfa/rotation.d.ts +1 -1
- package/dist/mfa/routes.d.ts +78 -2
- package/dist/mfa/sms.d.ts +7 -2
- package/dist/mfa/totp.d.ts +8 -2
- package/dist/mfa/types.d.ts +47 -0
- package/dist/server.js +288 -57
- package/dist/server.js.map +12 -12
- package/package.json +1 -1
- package/dist/client/dpop.test.d.ts +0 -1
package/dist/server.js
CHANGED
|
@@ -7181,7 +7181,45 @@ var createLockoutGuard = ({
|
|
|
7181
7181
|
});
|
|
7182
7182
|
|
|
7183
7183
|
// src/mfa/types.ts
|
|
7184
|
-
var
|
|
7184
|
+
var LEGACY_SMS_FACTOR_ID = "legacy-sms";
|
|
7185
|
+
var LEGACY_TOTP_FACTOR_ID = "legacy-totp";
|
|
7186
|
+
var getMfaFactors = (enrollment) => {
|
|
7187
|
+
if (enrollment.factors !== undefined)
|
|
7188
|
+
return enrollment.factors;
|
|
7189
|
+
const factors = [];
|
|
7190
|
+
if (enrollment.totpSecretCiphertext) {
|
|
7191
|
+
factors.push({
|
|
7192
|
+
id: LEGACY_TOTP_FACTOR_ID,
|
|
7193
|
+
label: "Authenticator app",
|
|
7194
|
+
secretCiphertext: enrollment.totpSecretCiphertext,
|
|
7195
|
+
type: "totp",
|
|
7196
|
+
verified: enrollment.totpVerified
|
|
7197
|
+
});
|
|
7198
|
+
}
|
|
7199
|
+
if (enrollment.smsPhone) {
|
|
7200
|
+
factors.push({
|
|
7201
|
+
id: LEGACY_SMS_FACTOR_ID,
|
|
7202
|
+
label: "Text message",
|
|
7203
|
+
phone: enrollment.smsPhone,
|
|
7204
|
+
type: "sms",
|
|
7205
|
+
verified: enrollment.smsVerified
|
|
7206
|
+
});
|
|
7207
|
+
}
|
|
7208
|
+
return factors;
|
|
7209
|
+
};
|
|
7210
|
+
var isMfaEnrolled = (enrollment) => enrollment !== undefined && (getMfaFactors(enrollment).some((factor) => factor.verified) || enrollment.backupCodeHashes.length > 0);
|
|
7211
|
+
var withMfaFactors = (enrollment, factors) => {
|
|
7212
|
+
const firstSms = factors.find((factor) => factor.type === "sms");
|
|
7213
|
+
const firstTotp = factors.find((factor) => factor.type === "totp");
|
|
7214
|
+
return {
|
|
7215
|
+
...enrollment,
|
|
7216
|
+
factors,
|
|
7217
|
+
smsPhone: firstSms?.phone,
|
|
7218
|
+
smsVerified: firstSms?.verified ?? false,
|
|
7219
|
+
totpSecretCiphertext: firstTotp?.secretCiphertext,
|
|
7220
|
+
totpVerified: firstTotp?.verified ?? false
|
|
7221
|
+
};
|
|
7222
|
+
};
|
|
7185
7223
|
|
|
7186
7224
|
// src/mfa/gate.ts
|
|
7187
7225
|
var createMfaGate = ({ getUserId, mfaStore }) => async (user) => isMfaEnrolled(await mfaStore.getEnrollment(getUserId(user)));
|
|
@@ -7397,6 +7435,8 @@ var hasRecentAuthentication = (session, maxAgeMs, now = Date.now()) => session.a
|
|
|
7397
7435
|
// src/mfa/sms.ts
|
|
7398
7436
|
var DECIMAL_RADIX2 = 10;
|
|
7399
7437
|
var MASK_VISIBLE_DIGITS = 4;
|
|
7438
|
+
var FACTOR_LABEL_MAX_LENGTH = 80;
|
|
7439
|
+
var DEFAULT_SMS_LABEL = "Text message";
|
|
7400
7440
|
var E164_PATTERN = /^\+[1-9]\d{7,14}$/u;
|
|
7401
7441
|
var generateNumericCode = (length) => {
|
|
7402
7442
|
const bytes = new Uint8Array(length);
|
|
@@ -7570,10 +7610,13 @@ var mfaSmsRoutes = ({
|
|
|
7570
7610
|
smsSetupRoute = "/auth/mfa/sms/setup",
|
|
7571
7611
|
smsVerifyRoute = "/auth/mfa/sms/verify"
|
|
7572
7612
|
}) => new Elysia18().use(sessionStore()).post(smsSetupRoute, {
|
|
7573
|
-
body: t12.Object({
|
|
7613
|
+
body: t12.Object({
|
|
7614
|
+
label: t12.Optional(t12.String({ maxLength: FACTOR_LABEL_MAX_LENGTH })),
|
|
7615
|
+
phone: t12.String()
|
|
7616
|
+
}),
|
|
7574
7617
|
cookie: t12.Cookie({ user_session_id: userSessionIdTypebox })
|
|
7575
7618
|
}, async ({
|
|
7576
|
-
body: { phone },
|
|
7619
|
+
body: { label, phone },
|
|
7577
7620
|
cookie: { user_session_id },
|
|
7578
7621
|
status,
|
|
7579
7622
|
store: { session }
|
|
@@ -7601,21 +7644,35 @@ var mfaSmsRoutes = ({
|
|
|
7601
7644
|
if (existing?.smsCodeSentAt !== undefined && now - existing.smsCodeSentAt < smsResendCooldownMs) {
|
|
7602
7645
|
return status("Too Many Requests", "SMS resend cooldown active");
|
|
7603
7646
|
}
|
|
7647
|
+
const base = existing ?? {
|
|
7648
|
+
backupCodeHashes: [],
|
|
7649
|
+
createdAt: now,
|
|
7650
|
+
smsVerified: false,
|
|
7651
|
+
totpVerified: false,
|
|
7652
|
+
updatedAt: now,
|
|
7653
|
+
userId
|
|
7654
|
+
};
|
|
7655
|
+
const factor = {
|
|
7656
|
+
id: crypto.randomUUID(),
|
|
7657
|
+
label: label?.trim() || DEFAULT_SMS_LABEL,
|
|
7658
|
+
phone,
|
|
7659
|
+
type: "sms",
|
|
7660
|
+
verified: false
|
|
7661
|
+
};
|
|
7662
|
+
const factors = [
|
|
7663
|
+
...getMfaFactors(base).filter((existingFactor) => existingFactor.type !== "sms" || existingFactor.verified),
|
|
7664
|
+
factor
|
|
7665
|
+
];
|
|
7604
7666
|
try {
|
|
7605
7667
|
await issueAndStoreSmsCode({
|
|
7606
7668
|
codeLength: smsCodeLength,
|
|
7607
7669
|
enrollment: {
|
|
7608
|
-
|
|
7609
|
-
createdAt: existing?.createdAt ?? now,
|
|
7610
|
-
lastUsedAt: existing?.lastUsedAt,
|
|
7670
|
+
...withMfaFactors(base, factors),
|
|
7611
7671
|
smsFailedAttempts: 0,
|
|
7672
|
+
smsPendingFactorId: factor.id,
|
|
7612
7673
|
smsPhone: phone,
|
|
7613
|
-
smsVerified: false,
|
|
7614
|
-
totpFailedAttempts: existing?.totpFailedAttempts ?? 0,
|
|
7615
|
-
totpSecretCiphertext: existing?.totpSecretCiphertext,
|
|
7616
|
-
totpVerified: existing?.totpVerified ?? false,
|
|
7617
7674
|
updatedAt: now,
|
|
7618
|
-
userId
|
|
7675
|
+
userId: base.userId
|
|
7619
7676
|
},
|
|
7620
7677
|
mfaStore,
|
|
7621
7678
|
onSendSmsCode,
|
|
@@ -7632,12 +7689,18 @@ var mfaSmsRoutes = ({
|
|
|
7632
7689
|
throw error;
|
|
7633
7690
|
return status(mapped.status, mapped.message);
|
|
7634
7691
|
}
|
|
7635
|
-
return status("OK", {
|
|
7692
|
+
return status("OK", {
|
|
7693
|
+
factorId: factor.id,
|
|
7694
|
+
phone: maskPhone(phone)
|
|
7695
|
+
});
|
|
7636
7696
|
}).post(smsVerifyRoute, {
|
|
7637
|
-
body: t12.Object({
|
|
7697
|
+
body: t12.Object({
|
|
7698
|
+
code: t12.String(),
|
|
7699
|
+
factorId: t12.Optional(t12.String())
|
|
7700
|
+
}),
|
|
7638
7701
|
cookie: t12.Cookie({ user_session_id: userSessionIdTypebox })
|
|
7639
7702
|
}, async ({
|
|
7640
|
-
body: { code },
|
|
7703
|
+
body: { code, factorId },
|
|
7641
7704
|
cookie: { user_session_id },
|
|
7642
7705
|
status,
|
|
7643
7706
|
store: { session }
|
|
@@ -7658,6 +7721,12 @@ var mfaSmsRoutes = ({
|
|
|
7658
7721
|
if (!enrollment?.smsPhone) {
|
|
7659
7722
|
return status("Bad Request", "No SMS enrollment in progress");
|
|
7660
7723
|
}
|
|
7724
|
+
const factors = getMfaFactors(enrollment);
|
|
7725
|
+
const pendingFactorId = factorId ?? enrollment.smsPendingFactorId;
|
|
7726
|
+
const pendingFactor = factors.find((candidate) => candidate.type === "sms" && (candidate.id === pendingFactorId || pendingFactorId === undefined && candidate.phone === enrollment.smsPhone));
|
|
7727
|
+
if (!pendingFactor || pendingFactor.type !== "sms") {
|
|
7728
|
+
return status("Bad Request", "No SMS enrollment in progress");
|
|
7729
|
+
}
|
|
7661
7730
|
const localCodeHash = enrollment.smsPendingCodeHash;
|
|
7662
7731
|
const challengeId = enrollment.smsChallengeId;
|
|
7663
7732
|
const localCodeExpiresAt = enrollment.smsPendingCodeExpiresAt;
|
|
@@ -7685,7 +7754,7 @@ var mfaSmsRoutes = ({
|
|
|
7685
7754
|
purpose: "mfa_enrollment",
|
|
7686
7755
|
reference: providerReference,
|
|
7687
7756
|
subject: userId,
|
|
7688
|
-
to:
|
|
7757
|
+
to: pendingFactor.phone
|
|
7689
7758
|
});
|
|
7690
7759
|
if (checked.error !== undefined) {
|
|
7691
7760
|
return status(checked.error.status, checked.error.message);
|
|
@@ -7701,8 +7770,10 @@ var mfaSmsRoutes = ({
|
|
|
7701
7770
|
});
|
|
7702
7771
|
return status(providerResult?.status === "max_attempts_reached" || attempts === undefined ? "Too Many Requests" : "Bad Request", providerResult?.status === "expired" ? "SMS code expired" : "Invalid SMS code");
|
|
7703
7772
|
}
|
|
7773
|
+
const verifiedFactors = factors.map((candidate) => candidate.id === pendingFactor.id ? { ...candidate, verified: true } : candidate);
|
|
7704
7774
|
const completed = await mfaStore.completeSmsChallenge({
|
|
7705
7775
|
challengeId,
|
|
7776
|
+
factors: verifiedFactors,
|
|
7706
7777
|
smsVerified: true,
|
|
7707
7778
|
userId
|
|
7708
7779
|
});
|
|
@@ -7732,15 +7803,50 @@ var mfaChallenge = ({
|
|
|
7732
7803
|
smsMaxAttempts = DEFAULT_SMS_MAX_ATTEMPTS,
|
|
7733
7804
|
smsResendCooldownMs = DEFAULT_SMS_RESEND_COOLDOWN_MS,
|
|
7734
7805
|
totpMaxAttempts = DEFAULT_TOTP_MAX_ATTEMPTS
|
|
7735
|
-
}) => new Elysia19().use(sessionStore()).
|
|
7806
|
+
}) => new Elysia19().use(sessionStore()).get(challengeRoute, { cookie: t13.Cookie({ user_session_id: userSessionIdTypebox }) }, async ({
|
|
7807
|
+
cookie: { user_session_id },
|
|
7808
|
+
status,
|
|
7809
|
+
store: { unregisteredSession }
|
|
7810
|
+
}) => {
|
|
7811
|
+
const compatibilityLayer = await createSessionCompatibilityLayer({
|
|
7812
|
+
authSessionStore,
|
|
7813
|
+
userSessionId: user_session_id.value
|
|
7814
|
+
});
|
|
7815
|
+
const challengeUnregistered = authSessionStore ? compatibilityLayer.unregisteredSession : unregisteredSession;
|
|
7816
|
+
const pendingId = user_session_id.value;
|
|
7817
|
+
const pending = pendingId ? challengeUnregistered[pendingId] : undefined;
|
|
7818
|
+
if (!pending) {
|
|
7819
|
+
return status("Unauthorized", "No MFA challenge in progress");
|
|
7820
|
+
}
|
|
7821
|
+
const user = await getChallengeUser(pending.userIdentity ?? {});
|
|
7822
|
+
const enrollment = user ? await mfaStore.getEnrollment(getUserId(user)) : undefined;
|
|
7823
|
+
if (!enrollment) {
|
|
7824
|
+
return status("Unauthorized", "No MFA challenge in progress");
|
|
7825
|
+
}
|
|
7826
|
+
const response = {
|
|
7827
|
+
backupCodesAvailable: enrollment.backupCodeHashes.length > 0,
|
|
7828
|
+
factors: getMfaFactors(enrollment).filter((factor) => factor.verified).map((factor) => ({
|
|
7829
|
+
id: factor.id,
|
|
7830
|
+
label: factor.label,
|
|
7831
|
+
phone: factor.type === "sms" ? maskPhone(factor.phone) : null,
|
|
7832
|
+
type: factor.type
|
|
7833
|
+
}))
|
|
7834
|
+
};
|
|
7835
|
+
return status("OK", response);
|
|
7836
|
+
}).post(challengeRoute, {
|
|
7736
7837
|
body: t13.Object({
|
|
7737
7838
|
action: t13.Optional(t13.Union([t13.Literal("send"), t13.Literal("verify")])),
|
|
7738
7839
|
code: t13.Optional(t13.String()),
|
|
7739
|
-
factor: t13.Optional(t13.
|
|
7840
|
+
factor: t13.Optional(t13.Union([
|
|
7841
|
+
t13.Literal("backup_codes"),
|
|
7842
|
+
t13.Literal("sms"),
|
|
7843
|
+
t13.Literal("totp")
|
|
7844
|
+
])),
|
|
7845
|
+
factorId: t13.Optional(t13.String())
|
|
7740
7846
|
}),
|
|
7741
7847
|
cookie: t13.Cookie({ user_session_id: userSessionIdTypebox })
|
|
7742
7848
|
}, async ({
|
|
7743
|
-
body: { action, code, factor },
|
|
7849
|
+
body: { action, code, factor, factorId },
|
|
7744
7850
|
cookie: { user_session_id },
|
|
7745
7851
|
status,
|
|
7746
7852
|
store: { session, unregisteredSession }
|
|
@@ -7761,6 +7867,7 @@ var mfaChallenge = ({
|
|
|
7761
7867
|
if (!user || !enrollment) {
|
|
7762
7868
|
return status("Unauthorized", "No MFA challenge in progress");
|
|
7763
7869
|
}
|
|
7870
|
+
const factors = getMfaFactors(enrollment);
|
|
7764
7871
|
const promote = async () => {
|
|
7765
7872
|
delete challengeUnregistered[pendingId];
|
|
7766
7873
|
const userSessionId = crypto.randomUUID();
|
|
@@ -7783,10 +7890,18 @@ var mfaChallenge = ({
|
|
|
7783
7890
|
if (enrollment.smsCodeSentAt !== undefined && Date.now() - enrollment.smsCodeSentAt < smsResendCooldownMs) {
|
|
7784
7891
|
return status("Too Many Requests", "SMS resend cooldown active");
|
|
7785
7892
|
}
|
|
7893
|
+
const smsFactor = factors.find((candidate) => candidate.type === "sms" && candidate.verified && (candidate.id === factorId || factorId === undefined));
|
|
7894
|
+
if (!smsFactor || smsFactor.type !== "sms") {
|
|
7895
|
+
return status("Bad Request", "SMS factor not found");
|
|
7896
|
+
}
|
|
7786
7897
|
try {
|
|
7787
7898
|
await issueAndStoreSmsCode({
|
|
7788
7899
|
codeLength: smsCodeLength,
|
|
7789
|
-
enrollment
|
|
7900
|
+
enrollment: {
|
|
7901
|
+
...withMfaFactors(enrollment, factors),
|
|
7902
|
+
smsPendingFactorId: smsFactor.id,
|
|
7903
|
+
smsPhone: smsFactor.phone
|
|
7904
|
+
},
|
|
7790
7905
|
mfaStore,
|
|
7791
7906
|
onSendSmsCode,
|
|
7792
7907
|
previousEnrollment: enrollment,
|
|
@@ -7802,10 +7917,19 @@ var mfaChallenge = ({
|
|
|
7802
7917
|
throw error;
|
|
7803
7918
|
return status(mapped.status, mapped.message);
|
|
7804
7919
|
}
|
|
7805
|
-
return status("OK", {
|
|
7920
|
+
return status("OK", {
|
|
7921
|
+
factorId: smsFactor.id,
|
|
7922
|
+
phone: maskPhone(smsFactor.phone),
|
|
7923
|
+
status: "sent"
|
|
7924
|
+
});
|
|
7806
7925
|
};
|
|
7807
7926
|
const runSmsChallenge = async () => {
|
|
7808
|
-
if (
|
|
7927
|
+
if (action !== "send" && factorId !== undefined && enrollment.smsPendingFactorId !== undefined && factorId !== enrollment.smsPendingFactorId) {
|
|
7928
|
+
return status("Bad Request", "No SMS code in progress");
|
|
7929
|
+
}
|
|
7930
|
+
const selectedFactorId = enrollment.smsPendingFactorId ?? factorId;
|
|
7931
|
+
const smsFactor = factors.find((candidate) => candidate.type === "sms" && candidate.verified && (candidate.id === selectedFactorId || selectedFactorId === undefined));
|
|
7932
|
+
if (!smsFactor || smsFactor.type !== "sms") {
|
|
7809
7933
|
return status("Unauthorized", "No MFA challenge in progress");
|
|
7810
7934
|
}
|
|
7811
7935
|
if (action === "send")
|
|
@@ -7844,7 +7968,7 @@ var mfaChallenge = ({
|
|
|
7844
7968
|
purpose: "mfa_challenge",
|
|
7845
7969
|
reference: providerReference,
|
|
7846
7970
|
subject: getUserId(user),
|
|
7847
|
-
to:
|
|
7971
|
+
to: smsFactor.phone
|
|
7848
7972
|
});
|
|
7849
7973
|
if (checked.error !== undefined) {
|
|
7850
7974
|
return status(checked.error.status, checked.error.message);
|
|
@@ -7866,6 +7990,7 @@ var mfaChallenge = ({
|
|
|
7866
7990
|
}
|
|
7867
7991
|
const completed = await mfaStore.completeSmsChallenge({
|
|
7868
7992
|
challengeId,
|
|
7993
|
+
factors,
|
|
7869
7994
|
lastUsedAt: Date.now(),
|
|
7870
7995
|
smsVerified: true,
|
|
7871
7996
|
userId: getUserId(user)
|
|
@@ -7887,11 +8012,14 @@ var mfaChallenge = ({
|
|
|
7887
8012
|
});
|
|
7888
8013
|
return status("Unauthorized", "Too many attempts");
|
|
7889
8014
|
}
|
|
7890
|
-
const
|
|
7891
|
-
|
|
8015
|
+
const totpFactors = factors.filter((candidate) => candidate.type === "totp" && candidate.verified && (candidate.id === factorId || factorId === undefined));
|
|
8016
|
+
const totpChecks = factor === "backup_codes" ? [] : await Promise.all(totpFactors.map(async (candidate) => verifyTotp({
|
|
8017
|
+
secret: await decryptTotpSecret(candidate.secretCiphertext, encryptionKey),
|
|
7892
8018
|
token: code
|
|
7893
|
-
})
|
|
7894
|
-
const
|
|
8019
|
+
})));
|
|
8020
|
+
const totpValid = totpChecks.some(Boolean);
|
|
8021
|
+
const mayUseBackupCode = factor === "backup_codes" || factor === undefined && factorId === undefined;
|
|
8022
|
+
const remainingBackupHashes = totpValid || !mayUseBackupCode ? undefined : await consumeBackupCode(code, enrollment.backupCodeHashes);
|
|
7895
8023
|
if (!totpValid && remainingBackupHashes === undefined) {
|
|
7896
8024
|
await mfaStore.saveEnrollment({
|
|
7897
8025
|
...enrollment,
|
|
@@ -7923,6 +8051,12 @@ var maskPhone2 = (phone) => {
|
|
|
7923
8051
|
const suffix = phone.slice(-visibleDigits);
|
|
7924
8052
|
return `${"*".repeat(Math.max(0, phone.length - visibleDigits))}${suffix}`;
|
|
7925
8053
|
};
|
|
8054
|
+
var publicFactors = (enrollment) => enrollment ? getMfaFactors(enrollment).filter((factor) => factor.verified).map((factor) => ({
|
|
8055
|
+
id: factor.id,
|
|
8056
|
+
label: factor.label,
|
|
8057
|
+
phone: factor.type === "sms" ? maskPhone2(factor.phone) : null,
|
|
8058
|
+
type: factor.type
|
|
8059
|
+
})) : [];
|
|
7926
8060
|
var mfaManagementRoutes = ({
|
|
7927
8061
|
authSessionStore,
|
|
7928
8062
|
getUserId,
|
|
@@ -7946,6 +8080,7 @@ var mfaManagementRoutes = ({
|
|
|
7946
8080
|
const response = {
|
|
7947
8081
|
backupCodesRemaining: enrollment?.backupCodeHashes.length ?? 0,
|
|
7948
8082
|
enabled: isMfaEnrolled(enrollment),
|
|
8083
|
+
factors: publicFactors(enrollment),
|
|
7949
8084
|
smsBackup: {
|
|
7950
8085
|
enabled: enrollment?.smsVerified ?? false,
|
|
7951
8086
|
phone: maskPhone2(enrollment?.smsPhone)
|
|
@@ -7971,11 +8106,54 @@ var mfaManagementRoutes = ({
|
|
|
7971
8106
|
}
|
|
7972
8107
|
await mfaStore.removeEnrollment(getUserId(userSession.user));
|
|
7973
8108
|
return status("OK", { status: "disabled" });
|
|
8109
|
+
}).delete(`${managementRoute}/factors/:factorId`, {
|
|
8110
|
+
cookie: t14.Cookie({ user_session_id: userSessionIdTypebox }),
|
|
8111
|
+
params: t14.Object({ factorId: t14.String() })
|
|
8112
|
+
}, async ({
|
|
8113
|
+
cookie: { user_session_id },
|
|
8114
|
+
params: { factorId },
|
|
8115
|
+
status,
|
|
8116
|
+
store: { session }
|
|
8117
|
+
}) => {
|
|
8118
|
+
const userSession = await loadSessionFromSource({
|
|
8119
|
+
authSessionStore,
|
|
8120
|
+
session,
|
|
8121
|
+
userSessionId: user_session_id.value
|
|
8122
|
+
});
|
|
8123
|
+
if (!userSession) {
|
|
8124
|
+
return status("Unauthorized", "Authentication required");
|
|
8125
|
+
}
|
|
8126
|
+
if (!hasRecentAuthentication(userSession, managementAuthMaxAgeMs)) {
|
|
8127
|
+
return status("Unauthorized", "Recent authentication required");
|
|
8128
|
+
}
|
|
8129
|
+
const userId = getUserId(userSession.user);
|
|
8130
|
+
const enrollment = await mfaStore.getEnrollment(userId);
|
|
8131
|
+
if (!enrollment) {
|
|
8132
|
+
return status("Not Found", "MFA factor not found");
|
|
8133
|
+
}
|
|
8134
|
+
const factors = getMfaFactors(enrollment);
|
|
8135
|
+
if (!factors.some((factor) => factor.id === factorId)) {
|
|
8136
|
+
return status("Not Found", "MFA factor not found");
|
|
8137
|
+
}
|
|
8138
|
+
const remaining = factors.filter((factor) => factor.id !== factorId);
|
|
8139
|
+
if (remaining.length === 0) {
|
|
8140
|
+
await mfaStore.removeEnrollment(userId);
|
|
8141
|
+
} else {
|
|
8142
|
+
const hasVerifiedFactor = remaining.some((factor) => factor.verified);
|
|
8143
|
+
await mfaStore.saveEnrollment(withMfaFactors({
|
|
8144
|
+
...enrollment,
|
|
8145
|
+
backupCodeHashes: hasVerifiedFactor ? enrollment.backupCodeHashes : [],
|
|
8146
|
+
updatedAt: Date.now()
|
|
8147
|
+
}, remaining));
|
|
8148
|
+
}
|
|
8149
|
+
return status("OK", { status: "removed" });
|
|
7974
8150
|
});
|
|
7975
8151
|
|
|
7976
8152
|
// src/mfa/totp.ts
|
|
7977
8153
|
init_crypto();
|
|
7978
8154
|
import { Elysia as Elysia21, t as t15 } from "elysia";
|
|
8155
|
+
var FACTOR_LABEL_MAX_LENGTH2 = 80;
|
|
8156
|
+
var DEFAULT_TOTP_LABEL = "Authenticator app";
|
|
7979
8157
|
var mfaTotpRoutes = ({
|
|
7980
8158
|
authSessionStore,
|
|
7981
8159
|
backupCodeCount = DEFAULT_BACKUP_CODE_COUNT,
|
|
@@ -7987,7 +8165,13 @@ var mfaTotpRoutes = ({
|
|
|
7987
8165
|
onMfaEnrolled,
|
|
7988
8166
|
totpSetupRoute = "/auth/mfa/totp/setup",
|
|
7989
8167
|
totpVerifyRoute = "/auth/mfa/totp/verify"
|
|
7990
|
-
}) => new Elysia21().use(sessionStore()).post(totpSetupRoute, {
|
|
8168
|
+
}) => new Elysia21().use(sessionStore()).post(totpSetupRoute, {
|
|
8169
|
+
body: t15.Object({
|
|
8170
|
+
label: t15.Optional(t15.String({ maxLength: FACTOR_LABEL_MAX_LENGTH2 }))
|
|
8171
|
+
}),
|
|
8172
|
+
cookie: t15.Cookie({ user_session_id: userSessionIdTypebox })
|
|
8173
|
+
}, async ({
|
|
8174
|
+
body: { label },
|
|
7991
8175
|
cookie: { user_session_id },
|
|
7992
8176
|
status,
|
|
7993
8177
|
store: { session }
|
|
@@ -8007,20 +8191,25 @@ var mfaTotpRoutes = ({
|
|
|
8007
8191
|
const secret = generateTotpSecret();
|
|
8008
8192
|
const existing = await mfaStore.getEnrollment(userId);
|
|
8009
8193
|
const now = Date.now();
|
|
8010
|
-
|
|
8011
|
-
backupCodeHashes:
|
|
8012
|
-
createdAt:
|
|
8013
|
-
|
|
8014
|
-
smsPendingCodeExpiresAt: existing?.smsPendingCodeExpiresAt,
|
|
8015
|
-
smsPendingCodeHash: existing?.smsPendingCodeHash,
|
|
8016
|
-
smsPhone: existing?.smsPhone,
|
|
8017
|
-
smsVerified: existing?.smsVerified ?? false,
|
|
8018
|
-
totpSecretCiphertext: await encryptTotpSecret(secret, encryptionKey),
|
|
8194
|
+
const base = existing ?? {
|
|
8195
|
+
backupCodeHashes: [],
|
|
8196
|
+
createdAt: now,
|
|
8197
|
+
smsVerified: false,
|
|
8019
8198
|
totpVerified: false,
|
|
8020
8199
|
updatedAt: now,
|
|
8021
8200
|
userId
|
|
8022
|
-
}
|
|
8201
|
+
};
|
|
8202
|
+
const factor = {
|
|
8203
|
+
id: crypto.randomUUID(),
|
|
8204
|
+
label: label?.trim() || DEFAULT_TOTP_LABEL,
|
|
8205
|
+
secretCiphertext: await encryptTotpSecret(secret, encryptionKey),
|
|
8206
|
+
type: "totp",
|
|
8207
|
+
verified: false
|
|
8208
|
+
};
|
|
8209
|
+
const factors = getMfaFactors(base).filter((existingFactor) => existingFactor.type !== "totp" || existingFactor.verified);
|
|
8210
|
+
await mfaStore.saveEnrollment(withMfaFactors({ ...base, updatedAt: now }, [...factors, factor]));
|
|
8023
8211
|
return status("OK", {
|
|
8212
|
+
factorId: factor.id,
|
|
8024
8213
|
secret,
|
|
8025
8214
|
uri: createTotpKeyUri({
|
|
8026
8215
|
accountName: userId,
|
|
@@ -8029,10 +8218,13 @@ var mfaTotpRoutes = ({
|
|
|
8029
8218
|
})
|
|
8030
8219
|
});
|
|
8031
8220
|
}).post(totpVerifyRoute, {
|
|
8032
|
-
body: t15.Object({
|
|
8221
|
+
body: t15.Object({
|
|
8222
|
+
code: t15.String(),
|
|
8223
|
+
factorId: t15.Optional(t15.String())
|
|
8224
|
+
}),
|
|
8033
8225
|
cookie: t15.Cookie({ user_session_id: userSessionIdTypebox })
|
|
8034
8226
|
}, async ({
|
|
8035
|
-
body: { code },
|
|
8227
|
+
body: { code, factorId },
|
|
8036
8228
|
cookie: { user_session_id },
|
|
8037
8229
|
status,
|
|
8038
8230
|
store: { session }
|
|
@@ -8050,21 +8242,26 @@ var mfaTotpRoutes = ({
|
|
|
8050
8242
|
}
|
|
8051
8243
|
const userId = getUserId(userSession.user);
|
|
8052
8244
|
const enrollment = await mfaStore.getEnrollment(userId);
|
|
8053
|
-
if (!enrollment
|
|
8245
|
+
if (!enrollment) {
|
|
8246
|
+
return status("Bad Request", "No TOTP enrollment in progress");
|
|
8247
|
+
}
|
|
8248
|
+
const factors = getMfaFactors(enrollment);
|
|
8249
|
+
const factor = factors.find((candidate) => candidate.type === "totp" && (candidate.id === factorId || factorId === undefined && !candidate.verified));
|
|
8250
|
+
if (!factor || factor.type !== "totp") {
|
|
8054
8251
|
return status("Bad Request", "No TOTP enrollment in progress");
|
|
8055
8252
|
}
|
|
8056
|
-
const secret = await decryptTotpSecret(
|
|
8253
|
+
const secret = await decryptTotpSecret(factor.secretCiphertext, encryptionKey);
|
|
8057
8254
|
const valid = await verifyTotp({ secret, token: code });
|
|
8058
8255
|
if (!valid) {
|
|
8059
8256
|
return status("Bad Request", "Invalid TOTP code");
|
|
8060
8257
|
}
|
|
8061
8258
|
const { codes, hashes } = await generateBackupCodes(backupCodeCount);
|
|
8062
|
-
|
|
8259
|
+
const verifiedFactors = factors.map((candidate) => candidate.id === factor.id ? { ...candidate, verified: true } : candidate);
|
|
8260
|
+
await mfaStore.saveEnrollment(withMfaFactors({
|
|
8063
8261
|
...enrollment,
|
|
8064
8262
|
backupCodeHashes: hashes,
|
|
8065
|
-
totpVerified: true,
|
|
8066
8263
|
updatedAt: Date.now()
|
|
8067
|
-
});
|
|
8264
|
+
}, verifiedFactors));
|
|
8068
8265
|
await onMfaEnrolled?.({ userId });
|
|
8069
8266
|
return status("OK", { backupCodes: codes });
|
|
8070
8267
|
});
|
|
@@ -35778,23 +35975,33 @@ var ensureReadable = async (ciphertext, key, userId) => {
|
|
|
35778
35975
|
}
|
|
35779
35976
|
};
|
|
35780
35977
|
var planEnrollment = async (enrollment, oldKey, newKey) => {
|
|
35781
|
-
const
|
|
35782
|
-
|
|
35978
|
+
const factors = getMfaFactors(enrollment);
|
|
35979
|
+
const totpFactors = factors.filter((factor) => factor.type === "totp");
|
|
35980
|
+
if (totpFactors.length === 0) {
|
|
35783
35981
|
const plan2 = { kind: "skip" };
|
|
35784
35982
|
return plan2;
|
|
35785
35983
|
}
|
|
35786
|
-
|
|
35787
|
-
|
|
35788
|
-
|
|
35984
|
+
let rotated = false;
|
|
35985
|
+
const nextFactors = await Promise.all(factors.map(async (factor) => {
|
|
35986
|
+
if (factor.type !== "totp")
|
|
35987
|
+
return factor;
|
|
35988
|
+
const secret = await tryDecrypt(factor.secretCiphertext, oldKey);
|
|
35989
|
+
if (secret === undefined) {
|
|
35990
|
+
await ensureReadable(factor.secretCiphertext, newKey, enrollment.userId);
|
|
35991
|
+
return factor;
|
|
35992
|
+
}
|
|
35993
|
+
rotated = true;
|
|
35994
|
+
return {
|
|
35995
|
+
...factor,
|
|
35996
|
+
secretCiphertext: await encryptTotpSecret(secret, newKey)
|
|
35997
|
+
};
|
|
35998
|
+
}));
|
|
35999
|
+
if (!rotated) {
|
|
35789
36000
|
const plan2 = { kind: "already" };
|
|
35790
36001
|
return plan2;
|
|
35791
36002
|
}
|
|
35792
36003
|
const plan = {
|
|
35793
|
-
enrollment: {
|
|
35794
|
-
...enrollment,
|
|
35795
|
-
totpSecretCiphertext: await encryptTotpSecret(secret, newKey),
|
|
35796
|
-
updatedAt: Date.now()
|
|
35797
|
-
},
|
|
36004
|
+
enrollment: withMfaFactors({ ...enrollment, updatedAt: Date.now() }, nextFactors),
|
|
35798
36005
|
kind: "rotate"
|
|
35799
36006
|
};
|
|
35800
36007
|
return plan;
|
|
@@ -35817,7 +36024,8 @@ var rotateMfaEncryptionKey = async ({
|
|
|
35817
36024
|
// src/mfa/inMemoryMfaStore.ts
|
|
35818
36025
|
var cloneEnrollment = (value) => ({
|
|
35819
36026
|
...value,
|
|
35820
|
-
backupCodeHashes: [...value.backupCodeHashes]
|
|
36027
|
+
backupCodeHashes: [...value.backupCodeHashes],
|
|
36028
|
+
factors: value.factors?.map((factor) => ({ ...factor }))
|
|
35821
36029
|
});
|
|
35822
36030
|
var createInMemoryMfaStore = () => {
|
|
35823
36031
|
const enrollments = new Map;
|
|
@@ -35839,6 +36047,7 @@ var createInMemoryMfaStore = () => {
|
|
|
35839
36047
|
},
|
|
35840
36048
|
completeSmsChallenge: async ({
|
|
35841
36049
|
challengeId,
|
|
36050
|
+
factors,
|
|
35842
36051
|
lastUsedAt,
|
|
35843
36052
|
smsVerified,
|
|
35844
36053
|
userId
|
|
@@ -35848,11 +36057,13 @@ var createInMemoryMfaStore = () => {
|
|
|
35848
36057
|
return false;
|
|
35849
36058
|
enrollments.set(userId, cloneEnrollment({
|
|
35850
36059
|
...current,
|
|
36060
|
+
factors: factors ?? current.factors,
|
|
35851
36061
|
lastUsedAt: lastUsedAt ?? current.lastUsedAt,
|
|
35852
36062
|
smsChallengeId: undefined,
|
|
35853
36063
|
smsFailedAttempts: 0,
|
|
35854
36064
|
smsPendingCodeExpiresAt: undefined,
|
|
35855
36065
|
smsPendingCodeHash: undefined,
|
|
36066
|
+
smsPendingFactorId: undefined,
|
|
35856
36067
|
smsPendingPurpose: undefined,
|
|
35857
36068
|
smsProviderReference: undefined,
|
|
35858
36069
|
smsVerified,
|
|
@@ -35913,6 +36124,7 @@ var mfaEnrollmentsTable = pgTable("auth_mfa_enrollments", {
|
|
|
35913
36124
|
backup_code_hashes: jsonb("backup_code_hashes").$type().notNull().default([]),
|
|
35914
36125
|
created_at_ms: bigint("created_at_ms", { mode: "number" }).notNull(),
|
|
35915
36126
|
last_used_at_ms: bigint("last_used_at_ms", { mode: "number" }),
|
|
36127
|
+
mfa_factors: jsonb("mfa_factors").$type(),
|
|
35916
36128
|
sms_challenge_id: text("sms_challenge_id"),
|
|
35917
36129
|
sms_code_sent_at_ms: bigint("sms_code_sent_at_ms", { mode: "number" }),
|
|
35918
36130
|
sms_failed_attempts: smallint("sms_failed_attempts").notNull().default(0),
|
|
@@ -35920,6 +36132,7 @@ var mfaEnrollmentsTable = pgTable("auth_mfa_enrollments", {
|
|
|
35920
36132
|
mode: "number"
|
|
35921
36133
|
}),
|
|
35922
36134
|
sms_pending_code_hash: text("sms_pending_code_hash"),
|
|
36135
|
+
sms_pending_factor_id: text("sms_pending_factor_id"),
|
|
35923
36136
|
sms_pending_purpose: text("sms_pending_purpose").$type(),
|
|
35924
36137
|
sms_phone: varchar("sms_phone", { length: PHONE_LENGTH }),
|
|
35925
36138
|
sms_provider_reference: text("sms_provider_reference"),
|
|
@@ -35933,12 +36146,14 @@ var mfaEnrollmentsTable = pgTable("auth_mfa_enrollments", {
|
|
|
35933
36146
|
var toEnrollment = (row) => ({
|
|
35934
36147
|
backupCodeHashes: row.backup_code_hashes,
|
|
35935
36148
|
createdAt: row.created_at_ms,
|
|
36149
|
+
factors: row.mfa_factors ?? undefined,
|
|
35936
36150
|
lastUsedAt: row.last_used_at_ms ?? undefined,
|
|
35937
36151
|
smsChallengeId: row.sms_challenge_id ?? undefined,
|
|
35938
36152
|
smsCodeSentAt: row.sms_code_sent_at_ms ?? undefined,
|
|
35939
36153
|
smsFailedAttempts: row.sms_failed_attempts,
|
|
35940
36154
|
smsPendingCodeExpiresAt: row.sms_pending_code_expires_at_ms ?? undefined,
|
|
35941
36155
|
smsPendingCodeHash: row.sms_pending_code_hash ?? undefined,
|
|
36156
|
+
smsPendingFactorId: row.sms_pending_factor_id ?? undefined,
|
|
35942
36157
|
smsPendingPurpose: row.sms_pending_purpose ?? undefined,
|
|
35943
36158
|
smsPhone: row.sms_phone ?? undefined,
|
|
35944
36159
|
smsProviderReference: row.sms_provider_reference ?? undefined,
|
|
@@ -35955,11 +36170,13 @@ var createPostgresMfaStore = (db) => {
|
|
|
35955
36170
|
backup_code_hashes: enrollment.backupCodeHashes,
|
|
35956
36171
|
created_at_ms: enrollment.createdAt,
|
|
35957
36172
|
last_used_at_ms: enrollment.lastUsedAt ?? null,
|
|
36173
|
+
mfa_factors: enrollment.factors ?? null,
|
|
35958
36174
|
sms_challenge_id: enrollment.smsChallengeId ?? null,
|
|
35959
36175
|
sms_code_sent_at_ms: enrollment.smsCodeSentAt ?? null,
|
|
35960
36176
|
sms_failed_attempts: enrollment.smsFailedAttempts ?? 0,
|
|
35961
36177
|
sms_pending_code_expires_at_ms: enrollment.smsPendingCodeExpiresAt ?? null,
|
|
35962
36178
|
sms_pending_code_hash: enrollment.smsPendingCodeHash ?? null,
|
|
36179
|
+
sms_pending_factor_id: enrollment.smsPendingFactorId ?? null,
|
|
35963
36180
|
sms_pending_purpose: enrollment.smsPendingPurpose ?? null,
|
|
35964
36181
|
sms_phone: enrollment.smsPhone ?? null,
|
|
35965
36182
|
sms_provider_reference: enrollment.smsProviderReference ?? null,
|
|
@@ -35982,11 +36199,13 @@ var createPostgresMfaStore = (db) => {
|
|
|
35982
36199
|
});
|
|
35983
36200
|
const rows = await db.insert(mfaEnrollmentsTable).values(values).onConflictDoUpdate({
|
|
35984
36201
|
set: {
|
|
36202
|
+
mfa_factors: enrollment.factors ?? null,
|
|
35985
36203
|
sms_challenge_id: challengeId,
|
|
35986
36204
|
sms_code_sent_at_ms: enrollment.smsCodeSentAt ?? null,
|
|
35987
36205
|
sms_failed_attempts: 0,
|
|
35988
36206
|
sms_pending_code_expires_at_ms: enrollment.smsPendingCodeExpiresAt ?? null,
|
|
35989
36207
|
sms_pending_code_hash: null,
|
|
36208
|
+
sms_pending_factor_id: enrollment.smsPendingFactorId ?? null,
|
|
35990
36209
|
sms_pending_purpose: enrollment.smsPendingPurpose ?? null,
|
|
35991
36210
|
sms_phone: enrollment.smsPhone ?? null,
|
|
35992
36211
|
sms_provider_reference: null,
|
|
@@ -36000,16 +36219,19 @@ var createPostgresMfaStore = (db) => {
|
|
|
36000
36219
|
},
|
|
36001
36220
|
completeSmsChallenge: async ({
|
|
36002
36221
|
challengeId,
|
|
36222
|
+
factors,
|
|
36003
36223
|
lastUsedAt,
|
|
36004
36224
|
smsVerified,
|
|
36005
36225
|
userId
|
|
36006
36226
|
}) => {
|
|
36007
36227
|
const rows = await db.update(mfaEnrollmentsTable).set({
|
|
36008
36228
|
last_used_at_ms: lastUsedAt,
|
|
36229
|
+
mfa_factors: factors,
|
|
36009
36230
|
sms_challenge_id: null,
|
|
36010
36231
|
sms_failed_attempts: 0,
|
|
36011
36232
|
sms_pending_code_expires_at_ms: null,
|
|
36012
36233
|
sms_pending_code_hash: null,
|
|
36234
|
+
sms_pending_factor_id: null,
|
|
36013
36235
|
sms_pending_purpose: null,
|
|
36014
36236
|
sms_provider_reference: null,
|
|
36015
36237
|
sms_verified: smsVerified,
|
|
@@ -40549,6 +40771,14 @@ var mfaSmsAtomicChallengeMigration = {
|
|
|
40549
40771
|
id: "0005_sms_atomic_challenge",
|
|
40550
40772
|
sql: 'ALTER TABLE "auth_mfa_enrollments" ADD COLUMN IF NOT EXISTS "sms_challenge_id" text;'
|
|
40551
40773
|
};
|
|
40774
|
+
var mfaMultipleFactorsMigration = {
|
|
40775
|
+
id: "0006_multiple_factors",
|
|
40776
|
+
sql: [
|
|
40777
|
+
'ALTER TABLE "auth_mfa_enrollments" ADD COLUMN IF NOT EXISTS "mfa_factors" jsonb;',
|
|
40778
|
+
'ALTER TABLE "auth_mfa_enrollments" ADD COLUMN IF NOT EXISTS "sms_pending_factor_id" text;'
|
|
40779
|
+
].join(`
|
|
40780
|
+
`)
|
|
40781
|
+
};
|
|
40552
40782
|
var oidcResourceAudienceMigration = {
|
|
40553
40783
|
id: "0002_resource_audience",
|
|
40554
40784
|
sql: [
|
|
@@ -40630,7 +40860,8 @@ var blockMigrations = {
|
|
|
40630
40860
|
mfaSmsColumnsMigration,
|
|
40631
40861
|
mfaTotpLockoutMigration,
|
|
40632
40862
|
mfaSmsDeliveryPolicyMigration,
|
|
40633
|
-
mfaSmsAtomicChallengeMigration
|
|
40863
|
+
mfaSmsAtomicChallengeMigration,
|
|
40864
|
+
mfaMultipleFactorsMigration
|
|
40634
40865
|
]
|
|
40635
40866
|
},
|
|
40636
40867
|
oidc: {
|
|
@@ -41672,5 +41903,5 @@ export {
|
|
|
41672
41903
|
VerificationProviderError
|
|
41673
41904
|
};
|
|
41674
41905
|
|
|
41675
|
-
//# debugId=
|
|
41906
|
+
//# debugId=5FE7751779ECD0D364756E2164756E21
|
|
41676
41907
|
//# sourceMappingURL=server.js.map
|