@lastshotlabs/bunshot 0.0.25 → 0.0.27
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/adapters/localStorage.js +20 -5
- package/dist/adapters/memoryAuth.d.ts +6 -0
- package/dist/adapters/memoryAuth.js +117 -2
- package/dist/adapters/mongoAuth.js +97 -1
- package/dist/adapters/sqliteAuth.d.ts +23 -0
- package/dist/adapters/sqliteAuth.js +153 -2
- package/dist/app.d.ts +105 -2
- package/dist/app.js +112 -9
- package/dist/index.d.ts +23 -4
- package/dist/index.js +13 -2
- package/dist/lib/HttpError.d.ts +2 -1
- package/dist/lib/HttpError.js +3 -1
- package/dist/lib/appConfig.d.ts +113 -0
- package/dist/lib/appConfig.js +38 -0
- package/dist/lib/auditLog.d.ts +6 -0
- package/dist/lib/auditLog.js +17 -0
- package/dist/lib/authAdapter.d.ts +71 -1
- package/dist/lib/authRateLimit.js +36 -0
- package/dist/lib/breachedPassword.d.ts +13 -0
- package/dist/lib/breachedPassword.js +48 -0
- package/dist/lib/captcha.d.ts +25 -0
- package/dist/lib/captcha.js +37 -0
- package/dist/lib/context.d.ts +5 -0
- package/dist/lib/credentialStuffing.d.ts +31 -0
- package/dist/lib/credentialStuffing.js +77 -0
- package/dist/lib/emailVerification.d.ts +6 -0
- package/dist/lib/emailVerification.js +46 -3
- package/dist/lib/jwks.d.ts +25 -0
- package/dist/lib/jwks.js +51 -0
- package/dist/lib/jwt.d.ts +15 -2
- package/dist/lib/jwt.js +92 -5
- package/dist/lib/logger.d.ts +2 -0
- package/dist/lib/logger.js +6 -0
- package/dist/lib/m2m.d.ts +29 -0
- package/dist/lib/m2m.js +48 -0
- package/dist/lib/mfaChallenge.d.ts +14 -1
- package/dist/lib/mfaChallenge.js +111 -6
- package/dist/lib/mongo.js +1 -1
- package/dist/lib/oauthCode.js +23 -18
- package/dist/lib/resetPassword.js +3 -1
- package/dist/lib/saml.d.ts +25 -0
- package/dist/lib/saml.js +64 -0
- package/dist/lib/scim.d.ts +44 -0
- package/dist/lib/scim.js +54 -0
- package/dist/lib/securityEvents.d.ts +28 -0
- package/dist/lib/securityEvents.js +26 -0
- package/dist/lib/session.d.ts +10 -0
- package/dist/lib/session.js +67 -5
- package/dist/lib/signing.js +5 -2
- package/dist/lib/suspension.d.ts +13 -0
- package/dist/lib/suspension.js +23 -0
- package/dist/lib/upload.d.ts +4 -0
- package/dist/lib/upload.js +26 -1
- package/dist/lib/uploadRegistry.d.ts +18 -0
- package/dist/lib/uploadRegistry.js +83 -0
- package/dist/lib/ws.js +7 -0
- package/dist/middleware/bearerAuth.js +1 -1
- package/dist/middleware/captcha.d.ts +10 -0
- package/dist/middleware/captcha.js +36 -0
- package/dist/middleware/csrf.js +8 -4
- package/dist/middleware/errorHandler.js +4 -1
- package/dist/middleware/identify.js +40 -13
- package/dist/middleware/requestSigning.js +6 -5
- package/dist/middleware/requireMfaSetup.js +2 -1
- package/dist/middleware/requireScope.d.ts +10 -0
- package/dist/middleware/requireScope.js +25 -0
- package/dist/middleware/requireStepUp.d.ts +18 -0
- package/dist/middleware/requireStepUp.js +29 -0
- package/dist/middleware/scimAuth.d.ts +8 -0
- package/dist/middleware/scimAuth.js +29 -0
- package/dist/middleware/webhookAuth.d.ts +1 -1
- package/dist/middleware/webhookAuth.js +6 -5
- package/dist/models/AuthUser.d.ts +7 -0
- package/dist/models/AuthUser.js +7 -0
- package/dist/models/M2MClient.d.ts +18 -0
- package/dist/models/M2MClient.js +18 -0
- package/dist/routes/auth.d.ts +3 -2
- package/dist/routes/auth.js +155 -16
- package/dist/routes/jobs.js +21 -3
- package/dist/routes/m2m.d.ts +2 -0
- package/dist/routes/m2m.js +72 -0
- package/dist/routes/metrics.d.ts +1 -0
- package/dist/routes/metrics.js +3 -0
- package/dist/routes/mfa.js +9 -1
- package/dist/routes/oauth.js +6 -0
- package/dist/routes/oidc.d.ts +2 -0
- package/dist/routes/oidc.js +29 -0
- package/dist/routes/passkey.d.ts +1 -0
- package/dist/routes/passkey.js +157 -0
- package/dist/routes/saml.d.ts +2 -0
- package/dist/routes/saml.js +86 -0
- package/dist/routes/scim.d.ts +2 -0
- package/dist/routes/scim.js +255 -0
- package/dist/routes/uploads.d.ts +13 -1
- package/dist/routes/uploads.js +98 -6
- package/dist/services/auth.d.ts +2 -0
- package/dist/services/auth.js +101 -22
- package/dist/services/mfa.js +2 -2
- package/dist/ws/index.js +2 -1
- package/docs/sections/auth-flow/full.md +790 -779
- package/docs/sections/auth-security-examples/full.md +23 -0
- package/docs/sections/metrics/full.md +6 -2
- package/docs/sections/passkey-login/full.md +90 -0
- package/docs/sections/passkey-login/overview.md +1 -0
- package/docs/sections/uploads/full.md +11 -2
- package/docs/sections/webhook-auth/full.md +1 -1
- package/docs/sections/websocket/full.md +12 -0
- package/package.json +3 -2
|
@@ -2,8 +2,8 @@ import { getRedis } from "./redis";
|
|
|
2
2
|
import { appConnection, mongoose } from "./mongo";
|
|
3
3
|
import { getAppName, getTokenExpiry } from "./appConfig";
|
|
4
4
|
import { sha256 } from "./crypto";
|
|
5
|
-
import { sqliteCreateVerificationToken, sqliteGetVerificationToken, sqliteDeleteVerificationToken, } from "../adapters/sqliteAuth";
|
|
6
|
-
import { memoryCreateVerificationToken, memoryGetVerificationToken, memoryDeleteVerificationToken, } from "../adapters/memoryAuth";
|
|
5
|
+
import { sqliteCreateVerificationToken, sqliteGetVerificationToken, sqliteDeleteVerificationToken, sqliteConsumeVerificationToken, } from "../adapters/sqliteAuth";
|
|
6
|
+
import { memoryCreateVerificationToken, memoryGetVerificationToken, memoryDeleteVerificationToken, memoryConsumeVerificationToken, } from "../adapters/memoryAuth";
|
|
7
7
|
function getVerificationModel() {
|
|
8
8
|
if (appConnection.models["EmailVerification"])
|
|
9
9
|
return appConnection.models["EmailVerification"];
|
|
@@ -16,6 +16,25 @@ function getVerificationModel() {
|
|
|
16
16
|
}, { collection: "email_verifications" });
|
|
17
17
|
return appConnection.model("EmailVerification", verificationSchema);
|
|
18
18
|
}
|
|
19
|
+
// ---------------------------------------------------------------------------
|
|
20
|
+
// Redis helpers
|
|
21
|
+
// ---------------------------------------------------------------------------
|
|
22
|
+
/** Atomically GET+DEL a key. Uses native GETDEL (Redis >= 6.2) with a Lua fallback. */
|
|
23
|
+
async function redisGetDel(key) {
|
|
24
|
+
const redis = getRedis();
|
|
25
|
+
if (typeof redis.getdel === "function") {
|
|
26
|
+
try {
|
|
27
|
+
return await redis.getdel(key);
|
|
28
|
+
}
|
|
29
|
+
catch (err) {
|
|
30
|
+
const msg = err?.message ?? "";
|
|
31
|
+
if (!/unknown command|ERR unknown command/i.test(msg))
|
|
32
|
+
throw err;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
const result = await redis.eval("local v = redis.call('GET', KEYS[1])\nif v then redis.call('DEL', KEYS[1]) end\nreturn v", 1, key);
|
|
36
|
+
return result ?? null;
|
|
37
|
+
}
|
|
19
38
|
let _store = "redis";
|
|
20
39
|
export const setEmailVerificationStore = (store) => { _store = store; };
|
|
21
40
|
// ---------------------------------------------------------------------------
|
|
@@ -24,7 +43,9 @@ export const setEmailVerificationStore = (store) => { _store = store; };
|
|
|
24
43
|
/** Create a verification token. Returns the raw token (for the email link).
|
|
25
44
|
* Only the SHA-256 hash is persisted in the store. */
|
|
26
45
|
export const createVerificationToken = async (userId, email) => {
|
|
27
|
-
const
|
|
46
|
+
const bytes = new Uint8Array(32);
|
|
47
|
+
crypto.getRandomValues(bytes);
|
|
48
|
+
const token = Buffer.from(bytes).toString("base64url");
|
|
28
49
|
const hash = sha256(token);
|
|
29
50
|
const ttl = getTokenExpiry();
|
|
30
51
|
if (_store === "memory") {
|
|
@@ -84,3 +105,25 @@ export const deleteVerificationToken = async (token) => {
|
|
|
84
105
|
}
|
|
85
106
|
await getRedis().del(`verify:${getAppName()}:${hash}`);
|
|
86
107
|
};
|
|
108
|
+
/** Atomically consume a verification token — returns its payload and deletes it in one operation.
|
|
109
|
+
* Returns null if the token is invalid, expired, or already used. */
|
|
110
|
+
export const consumeVerificationToken = async (token) => {
|
|
111
|
+
const hash = sha256(token);
|
|
112
|
+
if (_store === "memory")
|
|
113
|
+
return memoryConsumeVerificationToken(hash);
|
|
114
|
+
if (_store === "sqlite")
|
|
115
|
+
return sqliteConsumeVerificationToken(hash);
|
|
116
|
+
if (_store === "mongo") {
|
|
117
|
+
const doc = await getVerificationModel()
|
|
118
|
+
.findOneAndDelete({ token: hash, expiresAt: { $gt: new Date() } })
|
|
119
|
+
.lean();
|
|
120
|
+
if (!doc)
|
|
121
|
+
return null;
|
|
122
|
+
return { userId: doc.userId, email: doc.email };
|
|
123
|
+
}
|
|
124
|
+
// Redis: atomically return and remove the key (GETDEL or Lua fallback)
|
|
125
|
+
const raw = await redisGetDel(`verify:${getAppName()}:${hash}`);
|
|
126
|
+
if (!raw)
|
|
127
|
+
return null;
|
|
128
|
+
return JSON.parse(raw);
|
|
129
|
+
};
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { type JWK } from "jose";
|
|
2
|
+
export interface JwksKeyConfig {
|
|
3
|
+
privateKey: string;
|
|
4
|
+
publicKey: string;
|
|
5
|
+
kid?: string;
|
|
6
|
+
}
|
|
7
|
+
type KeyMaterial = CryptoKey;
|
|
8
|
+
export declare function loadJwksKey(config: JwksKeyConfig): Promise<void>;
|
|
9
|
+
export declare function loadPreviousKey(config: {
|
|
10
|
+
publicKey: string;
|
|
11
|
+
kid?: string;
|
|
12
|
+
}): Promise<void>;
|
|
13
|
+
export declare function generateAndLoadKeyPair(): Promise<{
|
|
14
|
+
privateKey: string;
|
|
15
|
+
publicKey: string;
|
|
16
|
+
}>;
|
|
17
|
+
export declare function getSigningPrivateKey(): KeyMaterial;
|
|
18
|
+
export declare function getVerifyPublicKeys(): KeyMaterial[];
|
|
19
|
+
export declare function getJwks(): {
|
|
20
|
+
keys: JWK[];
|
|
21
|
+
};
|
|
22
|
+
export declare function isJwksLoaded(): boolean;
|
|
23
|
+
/** @internal — reset for tests */
|
|
24
|
+
export declare function _resetJwksState(): void;
|
|
25
|
+
export {};
|
package/dist/lib/jwks.js
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { generateKeyPair, exportJWK, importPKCS8, importSPKI } from "jose";
|
|
2
|
+
let _primaryKey = null;
|
|
3
|
+
let _previousKeys = [];
|
|
4
|
+
export async function loadJwksKey(config) {
|
|
5
|
+
const kid = config.kid ?? "key-1";
|
|
6
|
+
const privateKey = await importPKCS8(config.privateKey, "RS256");
|
|
7
|
+
const publicKey = await importSPKI(config.publicKey, "RS256");
|
|
8
|
+
const jwk = await exportJWK(publicKey);
|
|
9
|
+
_primaryKey = { privateKey, publicKey, jwk: { ...jwk, kid, alg: "RS256", use: "sig" }, kid };
|
|
10
|
+
}
|
|
11
|
+
export async function loadPreviousKey(config) {
|
|
12
|
+
const kid = config.kid ?? `key-prev-${_previousKeys.length + 1}`;
|
|
13
|
+
const publicKey = await importSPKI(config.publicKey, "RS256");
|
|
14
|
+
const jwk = await exportJWK(publicKey);
|
|
15
|
+
_previousKeys.push({ privateKey: null, publicKey, jwk: { ...jwk, kid, alg: "RS256", use: "sig" }, kid });
|
|
16
|
+
}
|
|
17
|
+
export async function generateAndLoadKeyPair() {
|
|
18
|
+
const { privateKey: pk, publicKey: pubk } = await generateKeyPair("RS256", { modulusLength: 2048, extractable: true });
|
|
19
|
+
const { exportSPKI, exportPKCS8 } = await import("jose");
|
|
20
|
+
const privatePem = await exportPKCS8(pk);
|
|
21
|
+
const publicPem = await exportSPKI(pubk);
|
|
22
|
+
await loadJwksKey({ privateKey: privatePem, publicKey: publicPem, kid: "key-1" });
|
|
23
|
+
return { privateKey: privatePem, publicKey: publicPem };
|
|
24
|
+
}
|
|
25
|
+
export function getSigningPrivateKey() {
|
|
26
|
+
if (!_primaryKey)
|
|
27
|
+
throw new Error("RS256 requires OIDC key configuration — call loadJwksKey() first");
|
|
28
|
+
return _primaryKey.privateKey;
|
|
29
|
+
}
|
|
30
|
+
export function getVerifyPublicKeys() {
|
|
31
|
+
const keys = [];
|
|
32
|
+
if (_primaryKey)
|
|
33
|
+
keys.push(_primaryKey.publicKey);
|
|
34
|
+
keys.push(..._previousKeys.map((k) => k.publicKey));
|
|
35
|
+
return keys;
|
|
36
|
+
}
|
|
37
|
+
export function getJwks() {
|
|
38
|
+
const keys = [];
|
|
39
|
+
if (_primaryKey)
|
|
40
|
+
keys.push(_primaryKey.jwk);
|
|
41
|
+
keys.push(..._previousKeys.map((k) => k.jwk));
|
|
42
|
+
return { keys };
|
|
43
|
+
}
|
|
44
|
+
export function isJwksLoaded() {
|
|
45
|
+
return _primaryKey !== null;
|
|
46
|
+
}
|
|
47
|
+
/** @internal — reset for tests */
|
|
48
|
+
export function _resetJwksState() {
|
|
49
|
+
_primaryKey = null;
|
|
50
|
+
_previousKeys = [];
|
|
51
|
+
}
|
package/dist/lib/jwt.d.ts
CHANGED
|
@@ -1,2 +1,15 @@
|
|
|
1
|
-
|
|
2
|
-
export declare
|
|
1
|
+
import type { JWTPayload } from "jose";
|
|
2
|
+
export declare function validateJwtSecrets(): void;
|
|
3
|
+
export type TokenClaims = {
|
|
4
|
+
sub: string;
|
|
5
|
+
sid?: string;
|
|
6
|
+
scope?: string;
|
|
7
|
+
[key: string]: unknown;
|
|
8
|
+
};
|
|
9
|
+
export declare function signToken(claims: TokenClaims, expirySeconds?: number): Promise<string>;
|
|
10
|
+
export declare function signToken(userId: string, sessionId: string, expirySeconds?: number): Promise<string>;
|
|
11
|
+
export declare const verifyToken: (token: string) => Promise<JWTPayload>;
|
|
12
|
+
/** @internal — used by Feature 8 (OIDC) to switch to RS256 once key material is loaded */
|
|
13
|
+
export declare function _setAlgorithm(alg: string): void;
|
|
14
|
+
/** @internal — reset for testing */
|
|
15
|
+
export declare function _resetJwtState(): void;
|
package/dist/lib/jwt.js
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
import { SignJWT, jwtVerify } from "jose";
|
|
2
|
+
import { getJwtIssuer, getJwtAudience } from "./appConfig";
|
|
3
|
+
import { getSigningPrivateKey, getVerifyPublicKeys, isJwksLoaded } from "./jwks";
|
|
2
4
|
let _secret = null;
|
|
5
|
+
let _algorithm = "HS256";
|
|
3
6
|
function getSecret() {
|
|
4
7
|
if (_secret)
|
|
5
8
|
return _secret;
|
|
@@ -14,11 +17,95 @@ function getSecret() {
|
|
|
14
17
|
_secret = new TextEncoder().encode(rawSecret);
|
|
15
18
|
return _secret;
|
|
16
19
|
}
|
|
17
|
-
export
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
20
|
+
export function validateJwtSecrets() {
|
|
21
|
+
if (_algorithm !== "RS256") {
|
|
22
|
+
getSecret();
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
export async function signToken(claimsOrUserId, sessionIdOrExpiry, expirySeconds) {
|
|
26
|
+
let claims;
|
|
27
|
+
let expiry;
|
|
28
|
+
if (typeof claimsOrUserId === "string") {
|
|
29
|
+
// Legacy positional: signToken(userId, sessionId, expirySeconds?)
|
|
30
|
+
claims = { sub: claimsOrUserId, sid: sessionIdOrExpiry };
|
|
31
|
+
expiry = expirySeconds;
|
|
32
|
+
}
|
|
33
|
+
else {
|
|
34
|
+
// New object form: signToken(claims, expirySeconds?)
|
|
35
|
+
claims = claimsOrUserId;
|
|
36
|
+
expiry = sessionIdOrExpiry;
|
|
37
|
+
}
|
|
38
|
+
if (_algorithm === "RS256") {
|
|
39
|
+
if (!isJwksLoaded()) {
|
|
40
|
+
throw new Error("RS256 requires OIDC key configuration — call loadJwksKey() first");
|
|
41
|
+
}
|
|
42
|
+
// Use RS256 with JWKS key
|
|
43
|
+
const privateKey = getSigningPrivateKey();
|
|
44
|
+
const jwt = new SignJWT(claims)
|
|
45
|
+
.setProtectedHeader({ alg: "RS256", kid: "key-1" })
|
|
46
|
+
.setIssuedAt()
|
|
47
|
+
.setExpirationTime(expiry ? `${expiry}s` : "7d");
|
|
48
|
+
const issuer = getJwtIssuer();
|
|
49
|
+
const audience = getJwtAudience();
|
|
50
|
+
if (issuer)
|
|
51
|
+
jwt.setIssuer(issuer);
|
|
52
|
+
if (audience)
|
|
53
|
+
jwt.setAudience(audience);
|
|
54
|
+
return jwt.sign(privateKey);
|
|
55
|
+
}
|
|
56
|
+
const jwt = new SignJWT(claims)
|
|
57
|
+
.setProtectedHeader({ alg: _algorithm })
|
|
58
|
+
.setIssuedAt()
|
|
59
|
+
.setExpirationTime(expiry ? `${expiry}s` : "7d");
|
|
60
|
+
const issuer = getJwtIssuer();
|
|
61
|
+
const audience = getJwtAudience();
|
|
62
|
+
if (issuer)
|
|
63
|
+
jwt.setIssuer(issuer);
|
|
64
|
+
if (audience)
|
|
65
|
+
jwt.setAudience(audience);
|
|
66
|
+
return jwt.sign(getSecret());
|
|
67
|
+
}
|
|
21
68
|
export const verifyToken = async (token) => {
|
|
22
|
-
|
|
69
|
+
if (_algorithm === "RS256") {
|
|
70
|
+
if (!isJwksLoaded()) {
|
|
71
|
+
throw new Error("RS256 requires OIDC key configuration");
|
|
72
|
+
}
|
|
73
|
+
const publicKeys = getVerifyPublicKeys();
|
|
74
|
+
const opts = { algorithms: ["RS256"] };
|
|
75
|
+
const issuer = getJwtIssuer();
|
|
76
|
+
const audience = getJwtAudience();
|
|
77
|
+
if (issuer)
|
|
78
|
+
opts.issuer = issuer;
|
|
79
|
+
if (audience)
|
|
80
|
+
opts.audience = audience;
|
|
81
|
+
// Try each key (supports key rotation)
|
|
82
|
+
for (const key of publicKeys) {
|
|
83
|
+
try {
|
|
84
|
+
const { payload } = await jwtVerify(token, key, opts);
|
|
85
|
+
return payload;
|
|
86
|
+
}
|
|
87
|
+
catch {
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
throw new Error("JWT verification failed with all available keys");
|
|
92
|
+
}
|
|
93
|
+
const issuer = getJwtIssuer();
|
|
94
|
+
const audience = getJwtAudience();
|
|
95
|
+
const opts = { algorithms: [_algorithm] };
|
|
96
|
+
if (issuer)
|
|
97
|
+
opts.issuer = issuer;
|
|
98
|
+
if (audience)
|
|
99
|
+
opts.audience = audience;
|
|
100
|
+
const { payload } = await jwtVerify(token, getSecret(), opts);
|
|
23
101
|
return payload;
|
|
24
102
|
};
|
|
103
|
+
/** @internal — used by Feature 8 (OIDC) to switch to RS256 once key material is loaded */
|
|
104
|
+
export function _setAlgorithm(alg) {
|
|
105
|
+
_algorithm = alg;
|
|
106
|
+
}
|
|
107
|
+
/** @internal — reset for testing */
|
|
108
|
+
export function _resetJwtState() {
|
|
109
|
+
_secret = null;
|
|
110
|
+
_algorithm = "HS256";
|
|
111
|
+
}
|
package/dist/lib/logger.d.ts
CHANGED
package/dist/lib/logger.js
CHANGED
|
@@ -5,3 +5,9 @@ export const log = (...args) => {
|
|
|
5
5
|
if (verbose)
|
|
6
6
|
console.log(...args);
|
|
7
7
|
};
|
|
8
|
+
const authTraceEnabled = process.env.LOGGING_AUTH_TRACE === "true";
|
|
9
|
+
/** Like log(), but also requires LOGGING_AUTH_TRACE=true. Use for lines that include user/session IDs. */
|
|
10
|
+
export const authTrace = (...args) => {
|
|
11
|
+
if (authTraceEnabled)
|
|
12
|
+
log(...args);
|
|
13
|
+
};
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { M2MClientRecord } from "./authAdapter";
|
|
2
|
+
/**
|
|
3
|
+
* Look up an M2M client by clientId (active only).
|
|
4
|
+
* Returns the client record including clientSecretHash for verification.
|
|
5
|
+
*/
|
|
6
|
+
export declare function getM2MClient(clientId: string): Promise<(M2MClientRecord & {
|
|
7
|
+
clientSecretHash: string;
|
|
8
|
+
}) | null>;
|
|
9
|
+
/**
|
|
10
|
+
* Create a new M2M client. Returns the client ID and a plaintext secret (shown once).
|
|
11
|
+
* The secret is hashed with Bun.password before storage.
|
|
12
|
+
*/
|
|
13
|
+
export declare function createM2MClient(opts: {
|
|
14
|
+
clientId: string;
|
|
15
|
+
name: string;
|
|
16
|
+
scopes?: string[];
|
|
17
|
+
}): Promise<{
|
|
18
|
+
id: string;
|
|
19
|
+
clientId: string;
|
|
20
|
+
clientSecret: string;
|
|
21
|
+
}>;
|
|
22
|
+
/**
|
|
23
|
+
* Delete an M2M client by clientId.
|
|
24
|
+
*/
|
|
25
|
+
export declare function deleteM2MClient(clientId: string): Promise<void>;
|
|
26
|
+
/**
|
|
27
|
+
* List all M2M clients (secrets not included).
|
|
28
|
+
*/
|
|
29
|
+
export declare function listM2MClients(): Promise<M2MClientRecord[]>;
|
package/dist/lib/m2m.js
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { getAuthAdapter } from "./authAdapter";
|
|
2
|
+
/**
|
|
3
|
+
* Look up an M2M client by clientId (active only).
|
|
4
|
+
* Returns the client record including clientSecretHash for verification.
|
|
5
|
+
*/
|
|
6
|
+
export async function getM2MClient(clientId) {
|
|
7
|
+
const adapter = getAuthAdapter();
|
|
8
|
+
if (!adapter.getM2MClient)
|
|
9
|
+
return null;
|
|
10
|
+
return adapter.getM2MClient(clientId);
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Create a new M2M client. Returns the client ID and a plaintext secret (shown once).
|
|
14
|
+
* The secret is hashed with Bun.password before storage.
|
|
15
|
+
*/
|
|
16
|
+
export async function createM2MClient(opts) {
|
|
17
|
+
const adapter = getAuthAdapter();
|
|
18
|
+
if (!adapter.createM2MClient) {
|
|
19
|
+
throw new Error("Auth adapter does not support M2M clients");
|
|
20
|
+
}
|
|
21
|
+
const clientSecret = crypto.randomUUID() + "-" + crypto.randomUUID();
|
|
22
|
+
const clientSecretHash = await Bun.password.hash(clientSecret);
|
|
23
|
+
const { id } = await adapter.createM2MClient({
|
|
24
|
+
clientId: opts.clientId,
|
|
25
|
+
clientSecretHash,
|
|
26
|
+
name: opts.name,
|
|
27
|
+
scopes: opts.scopes ?? [],
|
|
28
|
+
});
|
|
29
|
+
return { id, clientId: opts.clientId, clientSecret };
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Delete an M2M client by clientId.
|
|
33
|
+
*/
|
|
34
|
+
export async function deleteM2MClient(clientId) {
|
|
35
|
+
const adapter = getAuthAdapter();
|
|
36
|
+
if (adapter.deleteM2MClient) {
|
|
37
|
+
await adapter.deleteM2MClient(clientId);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* List all M2M clients (secrets not included).
|
|
42
|
+
*/
|
|
43
|
+
export async function listM2MClients() {
|
|
44
|
+
const adapter = getAuthAdapter();
|
|
45
|
+
if (!adapter.listM2MClients)
|
|
46
|
+
return [];
|
|
47
|
+
return adapter.listM2MClients();
|
|
48
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export type MfaChallengePurpose = "login" | "webauthn-registration";
|
|
1
|
+
export type MfaChallengePurpose = "login" | "webauthn-registration" | "passkey-login";
|
|
2
2
|
export interface MfaChallengeOptions {
|
|
3
3
|
emailOtpHash?: string;
|
|
4
4
|
webauthnChallenge?: string;
|
|
@@ -39,4 +39,17 @@ export declare const consumeWebAuthnRegistrationChallenge: (token: string) => Pr
|
|
|
39
39
|
userId: string;
|
|
40
40
|
challenge: string;
|
|
41
41
|
} | null>;
|
|
42
|
+
/**
|
|
43
|
+
* Create a passkey login challenge token. Not tied to a user — userId is resolved
|
|
44
|
+
* from the credential after assertion. Uses a fixed 120s TTL.
|
|
45
|
+
*/
|
|
46
|
+
export declare const createPasskeyLoginChallenge: (challenge: string) => Promise<string>;
|
|
47
|
+
/**
|
|
48
|
+
* Consume a passkey login challenge token.
|
|
49
|
+
* Only accepts tokens with `purpose: "passkey-login"`.
|
|
50
|
+
* Returns the stored webauthnChallenge bytes or null if expired/invalid.
|
|
51
|
+
*/
|
|
52
|
+
export declare const consumePasskeyLoginChallenge: (token: string) => Promise<{
|
|
53
|
+
webauthnChallenge: string;
|
|
54
|
+
} | null>;
|
|
42
55
|
export {};
|
package/dist/lib/mfaChallenge.js
CHANGED
|
@@ -68,13 +68,35 @@ function ensureSqliteMfaTable() {
|
|
|
68
68
|
catch { /* already exists */ }
|
|
69
69
|
_sqliteTableCreated = true;
|
|
70
70
|
}
|
|
71
|
+
// ---------------------------------------------------------------------------
|
|
72
|
+
// Redis helpers
|
|
73
|
+
// ---------------------------------------------------------------------------
|
|
74
|
+
/** Atomically GET+DEL a key. Uses native GETDEL (Redis >= 6.2) with a Lua fallback. */
|
|
75
|
+
async function redisGetDel(key) {
|
|
76
|
+
const redis = getRedis();
|
|
77
|
+
if (typeof redis.getdel === "function") {
|
|
78
|
+
try {
|
|
79
|
+
return await redis.getdel(key);
|
|
80
|
+
}
|
|
81
|
+
catch (err) {
|
|
82
|
+
const msg = err?.message ?? "";
|
|
83
|
+
if (!/unknown command|ERR unknown command/i.test(msg))
|
|
84
|
+
throw err;
|
|
85
|
+
// Fall through to Lua on "unknown command"
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
const result = await redis.eval("local v = redis.call('GET', KEYS[1])\nif v then redis.call('DEL', KEYS[1]) end\nreturn v", 1, key);
|
|
89
|
+
return result ?? null;
|
|
90
|
+
}
|
|
71
91
|
let _store = "redis";
|
|
72
92
|
export const setMfaChallengeStore = (store) => { _store = store; };
|
|
73
93
|
// ---------------------------------------------------------------------------
|
|
74
94
|
// Public API
|
|
75
95
|
// ---------------------------------------------------------------------------
|
|
76
96
|
export const createMfaChallenge = async (userId, options) => {
|
|
77
|
-
const
|
|
97
|
+
const bytes = new Uint8Array(32);
|
|
98
|
+
crypto.getRandomValues(bytes);
|
|
99
|
+
const token = Buffer.from(bytes).toString("base64url");
|
|
78
100
|
const hash = sha256(token);
|
|
79
101
|
const ttl = getMfaChallengeTtl();
|
|
80
102
|
const now = Date.now();
|
|
@@ -135,10 +157,9 @@ export const consumeMfaChallenge = async (token) => {
|
|
|
135
157
|
}
|
|
136
158
|
// redis
|
|
137
159
|
const key = `mfachallenge:${getAppName()}:${hash}`;
|
|
138
|
-
const raw = await
|
|
160
|
+
const raw = await redisGetDel(key);
|
|
139
161
|
if (!raw)
|
|
140
162
|
return null;
|
|
141
|
-
await getRedis().del(key);
|
|
142
163
|
const data = JSON.parse(raw);
|
|
143
164
|
if (data.purpose !== "login")
|
|
144
165
|
return null;
|
|
@@ -220,7 +241,9 @@ export const replaceMfaChallengeOtp = async (token, newEmailOtpHash) => {
|
|
|
220
241
|
* uses `purpose: "webauthn-registration"` so it cannot be consumed by `consumeMfaChallenge`.
|
|
221
242
|
*/
|
|
222
243
|
export const createWebAuthnRegistrationChallenge = async (userId, challenge) => {
|
|
223
|
-
const
|
|
244
|
+
const bytes = new Uint8Array(32);
|
|
245
|
+
crypto.getRandomValues(bytes);
|
|
246
|
+
const token = Buffer.from(bytes).toString("base64url");
|
|
224
247
|
const hash = sha256(token);
|
|
225
248
|
const ttl = getMfaChallengeTtl();
|
|
226
249
|
const now = Date.now();
|
|
@@ -282,12 +305,94 @@ export const consumeWebAuthnRegistrationChallenge = async (token) => {
|
|
|
282
305
|
}
|
|
283
306
|
// redis
|
|
284
307
|
const key = `mfachallenge:${getAppName()}:${hash}`;
|
|
285
|
-
const raw = await
|
|
308
|
+
const raw = await redisGetDel(key);
|
|
286
309
|
if (!raw)
|
|
287
310
|
return null;
|
|
288
|
-
await getRedis().del(key);
|
|
289
311
|
const data = JSON.parse(raw);
|
|
290
312
|
if (data.purpose !== "webauthn-registration" || !data.webauthnChallenge)
|
|
291
313
|
return null;
|
|
292
314
|
return { userId: data.userId, challenge: data.webauthnChallenge };
|
|
293
315
|
};
|
|
316
|
+
// ---------------------------------------------------------------------------
|
|
317
|
+
// Passkey login challenge helpers (passwordless first-factor)
|
|
318
|
+
// ---------------------------------------------------------------------------
|
|
319
|
+
const PASSKEY_LOGIN_CHALLENGE_TTL = 120; // seconds — single-use, so longer TTL is safe
|
|
320
|
+
/**
|
|
321
|
+
* Create a passkey login challenge token. Not tied to a user — userId is resolved
|
|
322
|
+
* from the credential after assertion. Uses a fixed 120s TTL.
|
|
323
|
+
*/
|
|
324
|
+
export const createPasskeyLoginChallenge = async (challenge) => {
|
|
325
|
+
const bytes = new Uint8Array(32);
|
|
326
|
+
crypto.getRandomValues(bytes);
|
|
327
|
+
const token = Buffer.from(bytes).toString("base64url");
|
|
328
|
+
const hash = sha256(token);
|
|
329
|
+
const ttl = PASSKEY_LOGIN_CHALLENGE_TTL;
|
|
330
|
+
const now = Date.now();
|
|
331
|
+
const purpose = "passkey-login";
|
|
332
|
+
const userId = ""; // anonymous — resolved from credential ID at login time
|
|
333
|
+
if (_store === "memory") {
|
|
334
|
+
_memoryChallenges.set(hash, { userId, purpose, webauthnChallenge: challenge, createdAt: now, resendCount: 0, expiresAt: now + ttl * 1000 });
|
|
335
|
+
return token;
|
|
336
|
+
}
|
|
337
|
+
if (_store === "sqlite") {
|
|
338
|
+
ensureSqliteMfaTable();
|
|
339
|
+
_sqliteDb.run("INSERT INTO mfa_challenges (token, userId, purpose, webauthnChallenge, createdAt, resendCount, expiresAt) VALUES (?, ?, ?, ?, ?, 0, ?)", [hash, userId, purpose, challenge, now, now + ttl * 1000]);
|
|
340
|
+
return token;
|
|
341
|
+
}
|
|
342
|
+
if (_store === "mongo") {
|
|
343
|
+
await getMfaChallengeModel().create({
|
|
344
|
+
token: hash,
|
|
345
|
+
userId,
|
|
346
|
+
purpose,
|
|
347
|
+
webauthnChallenge: challenge,
|
|
348
|
+
createdAt: new Date(now),
|
|
349
|
+
resendCount: 0,
|
|
350
|
+
expiresAt: new Date(now + ttl * 1000),
|
|
351
|
+
});
|
|
352
|
+
return token;
|
|
353
|
+
}
|
|
354
|
+
// redis
|
|
355
|
+
await getRedis().set(`mfachallenge:${getAppName()}:${hash}`, JSON.stringify({ userId, purpose, webauthnChallenge: challenge, createdAt: now, resendCount: 0 }), "EX", ttl);
|
|
356
|
+
return token;
|
|
357
|
+
};
|
|
358
|
+
/**
|
|
359
|
+
* Consume a passkey login challenge token.
|
|
360
|
+
* Only accepts tokens with `purpose: "passkey-login"`.
|
|
361
|
+
* Returns the stored webauthnChallenge bytes or null if expired/invalid.
|
|
362
|
+
*/
|
|
363
|
+
export const consumePasskeyLoginChallenge = async (token) => {
|
|
364
|
+
const hash = sha256(token);
|
|
365
|
+
if (_store === "memory") {
|
|
366
|
+
const entry = _memoryChallenges.get(hash);
|
|
367
|
+
if (!entry || entry.expiresAt <= Date.now()) {
|
|
368
|
+
_memoryChallenges.delete(hash);
|
|
369
|
+
return null;
|
|
370
|
+
}
|
|
371
|
+
_memoryChallenges.delete(hash);
|
|
372
|
+
if (entry.purpose !== "passkey-login" || !entry.webauthnChallenge)
|
|
373
|
+
return null;
|
|
374
|
+
return { webauthnChallenge: entry.webauthnChallenge };
|
|
375
|
+
}
|
|
376
|
+
if (_store === "sqlite") {
|
|
377
|
+
ensureSqliteMfaTable();
|
|
378
|
+
const row = _sqliteDb.query("DELETE FROM mfa_challenges WHERE token = ? AND expiresAt > ? RETURNING purpose, webauthnChallenge").get(hash, Date.now());
|
|
379
|
+
if (!row || row.purpose !== "passkey-login" || !row.webauthnChallenge)
|
|
380
|
+
return null;
|
|
381
|
+
return { webauthnChallenge: row.webauthnChallenge };
|
|
382
|
+
}
|
|
383
|
+
if (_store === "mongo") {
|
|
384
|
+
const doc = await getMfaChallengeModel().findOneAndDelete({ token: hash, expiresAt: { $gt: new Date() } });
|
|
385
|
+
if (!doc || doc.purpose !== "passkey-login" || !doc.webauthnChallenge)
|
|
386
|
+
return null;
|
|
387
|
+
return { webauthnChallenge: doc.webauthnChallenge };
|
|
388
|
+
}
|
|
389
|
+
// redis
|
|
390
|
+
const key = `mfachallenge:${getAppName()}:${hash}`;
|
|
391
|
+
const raw = await redisGetDel(key);
|
|
392
|
+
if (!raw)
|
|
393
|
+
return null;
|
|
394
|
+
const data = JSON.parse(raw);
|
|
395
|
+
if (data.purpose !== "passkey-login" || !data.webauthnChallenge)
|
|
396
|
+
return null;
|
|
397
|
+
return { webauthnChallenge: data.webauthnChallenge };
|
|
398
|
+
};
|
package/dist/lib/mongo.js
CHANGED
|
@@ -13,7 +13,7 @@ function requireMongoose() {
|
|
|
13
13
|
}
|
|
14
14
|
function buildUri(user, password, host, db) {
|
|
15
15
|
const [hostPart, queryPart] = host.split("?");
|
|
16
|
-
return `mongodb+srv://${user}:${password}@${hostPart.replace(/\/$/, "")}/${db}${queryPart ? `?${queryPart}` : ""}`;
|
|
16
|
+
return `mongodb+srv://${encodeURIComponent(user)}:${encodeURIComponent(password)}@${hostPart.replace(/\/$/, "")}/${db}${queryPart ? `?${queryPart}` : ""}`;
|
|
17
17
|
}
|
|
18
18
|
// Internal mutable references — set inside connect functions
|
|
19
19
|
let _authConn = null;
|
package/dist/lib/oauthCode.js
CHANGED
|
@@ -18,6 +18,25 @@ function getOAuthCodeModel() {
|
|
|
18
18
|
}, { collection: "oauth_codes" });
|
|
19
19
|
return appConnection.model("OAuthCode", schema);
|
|
20
20
|
}
|
|
21
|
+
// ---------------------------------------------------------------------------
|
|
22
|
+
// Redis helpers
|
|
23
|
+
// ---------------------------------------------------------------------------
|
|
24
|
+
/** Atomically GET+DEL a key. Uses native GETDEL (Redis >= 6.2) with a Lua fallback. */
|
|
25
|
+
async function redisGetDel(key) {
|
|
26
|
+
const redis = getRedis();
|
|
27
|
+
if (typeof redis.getdel === "function") {
|
|
28
|
+
try {
|
|
29
|
+
return await redis.getdel(key);
|
|
30
|
+
}
|
|
31
|
+
catch (err) {
|
|
32
|
+
const msg = err?.message ?? "";
|
|
33
|
+
if (!/unknown command|ERR unknown command/i.test(msg))
|
|
34
|
+
throw err;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
const result = await redis.eval("local v = redis.call('GET', KEYS[1])\nif v then redis.call('DEL', KEYS[1]) end\nreturn v", 1, key);
|
|
38
|
+
return result ?? null;
|
|
39
|
+
}
|
|
21
40
|
let _store = "redis";
|
|
22
41
|
export const setOAuthCodeStore = (store) => { _store = store; };
|
|
23
42
|
const CODE_TTL = 60; // 60 seconds
|
|
@@ -27,7 +46,9 @@ const CODE_TTL = 60; // 60 seconds
|
|
|
27
46
|
/** Store a one-time authorization code. Returns the raw code (for the redirect URL).
|
|
28
47
|
* Only the SHA-256 hash is persisted. */
|
|
29
48
|
export const storeOAuthCode = async (payload) => {
|
|
30
|
-
const
|
|
49
|
+
const bytes = new Uint8Array(32);
|
|
50
|
+
crypto.getRandomValues(bytes);
|
|
51
|
+
const code = Buffer.from(bytes).toString("base64url");
|
|
31
52
|
const hash = sha256(code);
|
|
32
53
|
if (_store === "memory") {
|
|
33
54
|
memoryStoreOAuthCode(hash, payload, CODE_TTL);
|
|
@@ -67,23 +88,7 @@ export const consumeOAuthCode = async (code) => {
|
|
|
67
88
|
}
|
|
68
89
|
// Redis
|
|
69
90
|
const key = `oauthcode:${getAppName()}:${hash}`;
|
|
70
|
-
const
|
|
71
|
-
let raw = null;
|
|
72
|
-
if (typeof redis.getdel === "function") {
|
|
73
|
-
try {
|
|
74
|
-
raw = await redis.getdel(key);
|
|
75
|
-
}
|
|
76
|
-
catch {
|
|
77
|
-
raw = await redis.get(key);
|
|
78
|
-
if (raw)
|
|
79
|
-
await redis.del(key);
|
|
80
|
-
}
|
|
81
|
-
}
|
|
82
|
-
else {
|
|
83
|
-
raw = await redis.get(key);
|
|
84
|
-
if (raw)
|
|
85
|
-
await redis.del(key);
|
|
86
|
-
}
|
|
91
|
+
const raw = await redisGetDel(key);
|
|
87
92
|
if (!raw)
|
|
88
93
|
return null;
|
|
89
94
|
return JSON.parse(raw);
|
|
@@ -44,7 +44,9 @@ export const setPasswordResetStore = (store) => { _store = store; };
|
|
|
44
44
|
/** Create a reset token. Returns the raw token (to embed in the email link).
|
|
45
45
|
* Only the SHA-256 hash is persisted in the store. */
|
|
46
46
|
export const createResetToken = async (userId, email) => {
|
|
47
|
-
const
|
|
47
|
+
const bytes = new Uint8Array(32);
|
|
48
|
+
crypto.getRandomValues(bytes);
|
|
49
|
+
const token = Buffer.from(bytes).toString("base64url");
|
|
48
50
|
const hash = hashToken(token);
|
|
49
51
|
const ttl = getResetTokenExpiry();
|
|
50
52
|
if (_store === "memory") {
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { IdentityProfile } from "./authAdapter";
|
|
2
|
+
export interface SamlProfile {
|
|
3
|
+
nameId: string;
|
|
4
|
+
nameIdFormat?: string;
|
|
5
|
+
email?: string;
|
|
6
|
+
firstName?: string;
|
|
7
|
+
lastName?: string;
|
|
8
|
+
displayName?: string;
|
|
9
|
+
groups?: string[];
|
|
10
|
+
attributes: Record<string, string | string[]>;
|
|
11
|
+
}
|
|
12
|
+
export interface SamlAttributeMapping {
|
|
13
|
+
email?: string;
|
|
14
|
+
firstName?: string;
|
|
15
|
+
lastName?: string;
|
|
16
|
+
groups?: string;
|
|
17
|
+
}
|
|
18
|
+
export declare function initSaml(config: import("./appConfig").SamlConfig): Promise<void>;
|
|
19
|
+
export declare function createAuthnRequest(): {
|
|
20
|
+
redirectUrl: string;
|
|
21
|
+
id: string;
|
|
22
|
+
};
|
|
23
|
+
export declare function validateSamlResponse(body: string, config: import("./appConfig").SamlConfig): Promise<SamlProfile>;
|
|
24
|
+
export declare function samlProfileToIdentityProfile(profile: SamlProfile): IdentityProfile;
|
|
25
|
+
export declare function getSamlSpMetadata(): string;
|