@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
package/dist/lib/appConfig.d.ts
CHANGED
|
@@ -84,6 +84,10 @@ export interface MfaWebAuthnConfig {
|
|
|
84
84
|
timeout?: number;
|
|
85
85
|
/** Reject authentication when sign count goes backward (cloned key detection). Default: false (accept + warn). */
|
|
86
86
|
strictSignCount?: boolean;
|
|
87
|
+
/** Allow passwordless (first-factor) passkey login. When true, mounts POST /auth/passkey/login-options and POST /auth/passkey/login. Default: false. */
|
|
88
|
+
allowPasswordlessLogin?: boolean;
|
|
89
|
+
/** When true (default), a verified passkey login satisfies MFA — no subsequent TOTP/OTP prompt even if the user has MFA enabled. Set false to require MFA after passkey login. */
|
|
90
|
+
passkeyMfaBypass?: boolean;
|
|
87
91
|
}
|
|
88
92
|
export interface MfaConfig {
|
|
89
93
|
/** Issuer name shown in authenticator apps. Defaults to app name. */
|
|
@@ -117,6 +121,8 @@ export declare const getMfaEmailOtpConfig: () => MfaEmailOtpConfig | null;
|
|
|
117
121
|
export declare const getMfaEmailOtpCodeLength: () => number;
|
|
118
122
|
export declare const getMfaWebAuthnConfig: () => MfaWebAuthnConfig | null;
|
|
119
123
|
export declare const getMfaRequired: () => boolean;
|
|
124
|
+
export declare const getMfaWebAuthnAllowPasswordlessLogin: () => boolean;
|
|
125
|
+
export declare const getMfaWebAuthnPasskeyMfaBypass: () => boolean;
|
|
120
126
|
export declare const setCsrfEnabled: (v: boolean) => void;
|
|
121
127
|
export declare const getCsrfEnabled: () => boolean;
|
|
122
128
|
export interface SigningConfig {
|
|
@@ -160,3 +166,110 @@ export declare const getSigningConfig: () => SigningConfig | null;
|
|
|
160
166
|
* Returns null when neither is configured — callers must handle this gracefully.
|
|
161
167
|
*/
|
|
162
168
|
export declare const getSigningSecret: () => string | string[] | null;
|
|
169
|
+
export interface JwtConfig {
|
|
170
|
+
/** JWT issuer claim (`iss`). When set, added to all tokens and validated on verify. */
|
|
171
|
+
issuer?: string;
|
|
172
|
+
/** JWT audience claim (`aud`). When set, added to all tokens and validated on verify. */
|
|
173
|
+
audience?: string | string[];
|
|
174
|
+
/** JWT signing algorithm. Default: "HS256". Use "RS256" for OIDC. Requires OidcConfig when set to "RS256". */
|
|
175
|
+
algorithm?: "HS256" | "RS256";
|
|
176
|
+
}
|
|
177
|
+
export declare const setJwtConfig: (config: JwtConfig | null) => void;
|
|
178
|
+
export declare const getJwtConfig: () => JwtConfig | null;
|
|
179
|
+
export declare const getJwtIssuer: () => string | undefined;
|
|
180
|
+
export declare const getJwtAudience: () => string | string[] | undefined;
|
|
181
|
+
export interface BreachedPasswordConfig {
|
|
182
|
+
/** Block registration/reset when password is breached. Default: true. */
|
|
183
|
+
block?: boolean;
|
|
184
|
+
/** Minimum breach count to consider breached. Default: 1. */
|
|
185
|
+
minBreachCount?: number;
|
|
186
|
+
/** Request timeout in ms. Default: 3000. */
|
|
187
|
+
timeout?: number;
|
|
188
|
+
/** What to do when the HIBP API is unavailable. Default: "allow". */
|
|
189
|
+
onApiFailure?: "allow" | "block";
|
|
190
|
+
}
|
|
191
|
+
export declare const setBreachedPasswordConfig: (config: BreachedPasswordConfig | null) => void;
|
|
192
|
+
export declare const getBreachedPasswordConfig: () => BreachedPasswordConfig | null;
|
|
193
|
+
export interface StepUpConfig {
|
|
194
|
+
/** Max age in seconds since last MFA verification. Default: 300 (5 min). */
|
|
195
|
+
maxAge?: number;
|
|
196
|
+
}
|
|
197
|
+
export declare const setStepUpConfig: (config: StepUpConfig | null) => void;
|
|
198
|
+
export declare const getStepUpConfig: () => StepUpConfig | null;
|
|
199
|
+
export declare const setCheckSuspensionOnIdentify: (v: boolean) => void;
|
|
200
|
+
export declare const getCheckSuspensionOnIdentify: () => boolean;
|
|
201
|
+
export declare const setCaptchaConfig: (config: import("./captcha").CaptchaConfig | null) => void;
|
|
202
|
+
export declare const getCaptchaConfig: () => import("./captcha").CaptchaConfig | null;
|
|
203
|
+
export interface M2MConfig {
|
|
204
|
+
enabled?: boolean;
|
|
205
|
+
/** Access token expiry in seconds. Default: 3600 (1 hour). */
|
|
206
|
+
tokenExpiry?: number;
|
|
207
|
+
/** Allowed scopes for M2M clients. */
|
|
208
|
+
scopes?: string[];
|
|
209
|
+
}
|
|
210
|
+
export declare const setM2MConfig: (config: M2MConfig | null) => void;
|
|
211
|
+
export declare const getM2MConfig: () => M2MConfig | null;
|
|
212
|
+
export declare const getM2MTokenExpiry: () => number;
|
|
213
|
+
export interface SamlConfig {
|
|
214
|
+
/** Service Provider entity ID (e.g. "https://yourapp.com/auth/saml"). */
|
|
215
|
+
entityId: string;
|
|
216
|
+
/** Assertion Consumer Service URL. */
|
|
217
|
+
acsUrl: string;
|
|
218
|
+
/** IdP metadata — XML string or URL. */
|
|
219
|
+
idpMetadata: string;
|
|
220
|
+
/** SP signing private key PEM. Optional. */
|
|
221
|
+
signingKey?: string;
|
|
222
|
+
/** SP signing certificate PEM. Optional. */
|
|
223
|
+
signingCert?: string;
|
|
224
|
+
/** Map IdP attribute names to profile fields. */
|
|
225
|
+
attributeMapping?: {
|
|
226
|
+
email?: string;
|
|
227
|
+
firstName?: string;
|
|
228
|
+
lastName?: string;
|
|
229
|
+
groups?: string;
|
|
230
|
+
};
|
|
231
|
+
/** Custom user lookup/creation. When provided, takes precedence over findOrCreateByProvider. */
|
|
232
|
+
onLogin?: (profile: SamlProfile) => Promise<{
|
|
233
|
+
userId: string;
|
|
234
|
+
}>;
|
|
235
|
+
/** Where to redirect after successful SAML login. Default: "/". */
|
|
236
|
+
postLoginRedirect?: string;
|
|
237
|
+
}
|
|
238
|
+
import type { SamlProfile } from "./saml";
|
|
239
|
+
export declare const setSamlConfig: (config: SamlConfig | null) => void;
|
|
240
|
+
export declare const getSamlConfig: () => SamlConfig | null;
|
|
241
|
+
export interface OidcConfig {
|
|
242
|
+
enabled?: boolean;
|
|
243
|
+
/** JWT issuer — included in all tokens and OIDC discovery doc. Required. */
|
|
244
|
+
issuer: string;
|
|
245
|
+
/** RSA signing key. If not provided, a key pair is auto-generated on startup. */
|
|
246
|
+
signingKey?: {
|
|
247
|
+
privateKey: string;
|
|
248
|
+
publicKey: string;
|
|
249
|
+
kid?: string;
|
|
250
|
+
};
|
|
251
|
+
/** Previous signing keys for rotation (verification only). */
|
|
252
|
+
previousKeys?: Array<{
|
|
253
|
+
publicKey: string;
|
|
254
|
+
kid?: string;
|
|
255
|
+
}>;
|
|
256
|
+
/** Scopes advertised in the discovery document. Default: ["openid"]. */
|
|
257
|
+
scopes?: string[];
|
|
258
|
+
/** Token endpoint URL. Defaults to `${issuer}/oauth/token`. */
|
|
259
|
+
tokenEndpoint?: string;
|
|
260
|
+
}
|
|
261
|
+
export declare const setOidcConfig: (config: OidcConfig | null) => void;
|
|
262
|
+
export declare const getOidcConfig: () => OidcConfig | null;
|
|
263
|
+
export interface ScimConfig {
|
|
264
|
+
enabled?: boolean;
|
|
265
|
+
/** Bearer token(s) for SCIM endpoint authentication. Required. */
|
|
266
|
+
bearerTokens: string | string[];
|
|
267
|
+
/** Username mapping strategy. Default: "email". */
|
|
268
|
+
userMapping?: {
|
|
269
|
+
userName?: "email" | "username";
|
|
270
|
+
};
|
|
271
|
+
/** What to do when a user is deleted via SCIM. Default: "suspend". */
|
|
272
|
+
onDeprovision?: "suspend" | "delete" | ((userId: string) => Promise<void>);
|
|
273
|
+
}
|
|
274
|
+
export declare const setScimConfig: (config: ScimConfig | null) => void;
|
|
275
|
+
export declare const getScimConfig: () => ScimConfig | null;
|
package/dist/lib/appConfig.js
CHANGED
|
@@ -60,6 +60,8 @@ export const getMfaEmailOtpConfig = () => _mfaConfig?.emailOtp ?? null;
|
|
|
60
60
|
export const getMfaEmailOtpCodeLength = () => _mfaConfig?.emailOtp?.codeLength ?? 6;
|
|
61
61
|
export const getMfaWebAuthnConfig = () => _mfaConfig?.webauthn ?? null;
|
|
62
62
|
export const getMfaRequired = () => _mfaConfig?.required ?? false;
|
|
63
|
+
export const getMfaWebAuthnAllowPasswordlessLogin = () => _mfaConfig?.webauthn?.allowPasswordlessLogin ?? false;
|
|
64
|
+
export const getMfaWebAuthnPasskeyMfaBypass = () => _mfaConfig?.webauthn?.passkeyMfaBypass ?? true;
|
|
63
65
|
// ---------------------------------------------------------------------------
|
|
64
66
|
// CSRF config
|
|
65
67
|
// ---------------------------------------------------------------------------
|
|
@@ -81,3 +83,39 @@ export const getSigningSecret = () => {
|
|
|
81
83
|
const rawSecret = process.env[envKey];
|
|
82
84
|
return rawSecret ?? null;
|
|
83
85
|
};
|
|
86
|
+
let _jwtConfig = null;
|
|
87
|
+
export const setJwtConfig = (config) => { _jwtConfig = config; };
|
|
88
|
+
export const getJwtConfig = () => _jwtConfig;
|
|
89
|
+
export const getJwtIssuer = () => _jwtConfig?.issuer;
|
|
90
|
+
export const getJwtAudience = () => _jwtConfig?.audience;
|
|
91
|
+
let _breachedPasswordConfig = null;
|
|
92
|
+
export const setBreachedPasswordConfig = (config) => { _breachedPasswordConfig = config; };
|
|
93
|
+
export const getBreachedPasswordConfig = () => _breachedPasswordConfig;
|
|
94
|
+
let _stepUpConfig = null;
|
|
95
|
+
export const setStepUpConfig = (config) => { _stepUpConfig = config; };
|
|
96
|
+
export const getStepUpConfig = () => _stepUpConfig;
|
|
97
|
+
// ---------------------------------------------------------------------------
|
|
98
|
+
// Suspension config
|
|
99
|
+
// ---------------------------------------------------------------------------
|
|
100
|
+
let _checkSuspensionOnIdentify = false;
|
|
101
|
+
export const setCheckSuspensionOnIdentify = (v) => { _checkSuspensionOnIdentify = v; };
|
|
102
|
+
export const getCheckSuspensionOnIdentify = () => _checkSuspensionOnIdentify;
|
|
103
|
+
// ---------------------------------------------------------------------------
|
|
104
|
+
// CAPTCHA config
|
|
105
|
+
// ---------------------------------------------------------------------------
|
|
106
|
+
let _captchaConfig = null;
|
|
107
|
+
export const setCaptchaConfig = (config) => { _captchaConfig = config; };
|
|
108
|
+
export const getCaptchaConfig = () => _captchaConfig;
|
|
109
|
+
let _m2mConfig = null;
|
|
110
|
+
export const setM2MConfig = (config) => { _m2mConfig = config; };
|
|
111
|
+
export const getM2MConfig = () => _m2mConfig;
|
|
112
|
+
export const getM2MTokenExpiry = () => _m2mConfig?.tokenExpiry ?? 3600;
|
|
113
|
+
let _samlConfig = null;
|
|
114
|
+
export const setSamlConfig = (config) => { _samlConfig = config; };
|
|
115
|
+
export const getSamlConfig = () => _samlConfig;
|
|
116
|
+
let _oidcConfig = null;
|
|
117
|
+
export const setOidcConfig = (config) => { _oidcConfig = config; };
|
|
118
|
+
export const getOidcConfig = () => _oidcConfig;
|
|
119
|
+
let _scimConfig = null;
|
|
120
|
+
export const setScimConfig = (config) => { _scimConfig = config; };
|
|
121
|
+
export const getScimConfig = () => _scimConfig;
|
package/dist/lib/auditLog.d.ts
CHANGED
|
@@ -42,6 +42,12 @@ export declare function clearAuditLogMemoryStore(): void;
|
|
|
42
42
|
* storage failures never fail the HTTP request.
|
|
43
43
|
*/
|
|
44
44
|
export declare function logAuditEntry(entry: AuditLogEntry, options: AuditLogOptions): Promise<void>;
|
|
45
|
+
/**
|
|
46
|
+
* Blocking variant of logAuditEntry for critical events (e.g. account deletion,
|
|
47
|
+
* password changes). Awaits the write and logs errors, but never rethrows —
|
|
48
|
+
* a logging failure must not break the HTTP response.
|
|
49
|
+
*/
|
|
50
|
+
export declare function logAuditEntryBlocking(entry: AuditLogEntry, options: AuditLogOptions): Promise<void>;
|
|
45
51
|
/**
|
|
46
52
|
* Query audit log entries from the configured store.
|
|
47
53
|
* Returns `{ items, total }` where `total` is the filtered count before pagination.
|
package/dist/lib/auditLog.js
CHANGED
|
@@ -88,6 +88,23 @@ export async function logAuditEntry(entry, options) {
|
|
|
88
88
|
}
|
|
89
89
|
}
|
|
90
90
|
// ---------------------------------------------------------------------------
|
|
91
|
+
// logAuditEntryBlocking
|
|
92
|
+
// ---------------------------------------------------------------------------
|
|
93
|
+
/**
|
|
94
|
+
* Blocking variant of logAuditEntry for critical events (e.g. account deletion,
|
|
95
|
+
* password changes). Awaits the write and logs errors, but never rethrows —
|
|
96
|
+
* a logging failure must not break the HTTP response.
|
|
97
|
+
*/
|
|
98
|
+
export async function logAuditEntryBlocking(entry, options) {
|
|
99
|
+
try {
|
|
100
|
+
await logAuditEntry(entry, options);
|
|
101
|
+
}
|
|
102
|
+
catch (err) {
|
|
103
|
+
console.error("[auditLog] CRITICAL: blocking audit write failed:", err);
|
|
104
|
+
// Don't rethrow — we never want a logging failure to break the response
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
// ---------------------------------------------------------------------------
|
|
91
108
|
// getAuditLogs
|
|
92
109
|
// ---------------------------------------------------------------------------
|
|
93
110
|
/**
|
|
@@ -1,10 +1,23 @@
|
|
|
1
1
|
import type { GroupRecord, GroupMembershipRecord, PaginationOpts, PaginatedResult } from "./groups";
|
|
2
2
|
export type { GroupRecord, GroupMembershipRecord, PaginationOpts, PaginatedResult };
|
|
3
|
-
export interface
|
|
3
|
+
export interface M2MClientRecord {
|
|
4
|
+
id: string;
|
|
5
|
+
clientId: string;
|
|
6
|
+
name: string;
|
|
7
|
+
scopes: string[];
|
|
8
|
+
active: boolean;
|
|
9
|
+
}
|
|
10
|
+
export interface IdentityProfile {
|
|
4
11
|
email?: string;
|
|
5
12
|
name?: string;
|
|
13
|
+
firstName?: string;
|
|
14
|
+
lastName?: string;
|
|
15
|
+
displayName?: string;
|
|
6
16
|
avatarUrl?: string;
|
|
17
|
+
externalId?: string;
|
|
7
18
|
}
|
|
19
|
+
/** @deprecated Use IdentityProfile */
|
|
20
|
+
export type OAuthProfile = IdentityProfile;
|
|
8
21
|
export interface WebAuthnCredential {
|
|
9
22
|
/** Base64url-encoded credential ID. */
|
|
10
23
|
credentialId: string;
|
|
@@ -49,6 +62,12 @@ export interface AuthAdapter {
|
|
|
49
62
|
email?: string;
|
|
50
63
|
providerIds?: string[];
|
|
51
64
|
emailVerified?: boolean;
|
|
65
|
+
displayName?: string;
|
|
66
|
+
firstName?: string;
|
|
67
|
+
lastName?: string;
|
|
68
|
+
externalId?: string;
|
|
69
|
+
suspended?: boolean;
|
|
70
|
+
suspendedReason?: string;
|
|
52
71
|
} | null>;
|
|
53
72
|
/** Optional. Unlink a provider identity from a user (used by DELETE /auth/:provider/link). */
|
|
54
73
|
unlinkProvider?(userId: string, provider: string): Promise<void>;
|
|
@@ -104,6 +123,20 @@ export interface AuthAdapter {
|
|
|
104
123
|
updateWebAuthnCredentialSignCount?(userId: string, credentialId: string, signCount: number): Promise<void>;
|
|
105
124
|
/** Optional. Find the user who owns a WebAuthn credential. Returns userId or null. Used for cross-user uniqueness checks. */
|
|
106
125
|
findUserByWebAuthnCredentialId?(credentialId: string): Promise<string | null>;
|
|
126
|
+
/** Suspend or unsuspend a user. */
|
|
127
|
+
setSuspended?(userId: string, suspended: boolean, reason?: string): Promise<void>;
|
|
128
|
+
/** Get suspension status. Returns false if adapter doesn't track it. */
|
|
129
|
+
getSuspended?(userId: string): Promise<{
|
|
130
|
+
suspended: boolean;
|
|
131
|
+
suspendedReason?: string;
|
|
132
|
+
} | null>;
|
|
133
|
+
/** Update profile fields. */
|
|
134
|
+
updateProfile?(userId: string, fields: Partial<Pick<IdentityProfile, "displayName" | "firstName" | "lastName" | "externalId">>): Promise<void>;
|
|
135
|
+
/** List users matching a normalized query. */
|
|
136
|
+
listUsers?(query: UserQuery): Promise<{
|
|
137
|
+
users: UserRecord[];
|
|
138
|
+
totalResults: number;
|
|
139
|
+
}>;
|
|
107
140
|
/**
|
|
108
141
|
* Create a new group. Returns the new group's id.
|
|
109
142
|
* The name must be a slug (/^[a-z0-9_-]+$/) and unique within its scope.
|
|
@@ -171,6 +204,43 @@ export interface AuthAdapter {
|
|
|
171
204
|
* Tenant-scoped group roles NEVER satisfy app-wide role checks and vice versa.
|
|
172
205
|
*/
|
|
173
206
|
getEffectiveRoles?(userId: string, tenantId: string | null): Promise<string[]>;
|
|
207
|
+
/** Optional. Look up an active M2M client by clientId (includes clientSecretHash for verification). */
|
|
208
|
+
getM2MClient?(clientId: string): Promise<(M2MClientRecord & {
|
|
209
|
+
clientSecretHash: string;
|
|
210
|
+
}) | null>;
|
|
211
|
+
/** Optional. Create a new M2M client. Returns the new client's id. */
|
|
212
|
+
createM2MClient?(client: {
|
|
213
|
+
clientId: string;
|
|
214
|
+
clientSecretHash: string;
|
|
215
|
+
name: string;
|
|
216
|
+
scopes: string[];
|
|
217
|
+
}): Promise<{
|
|
218
|
+
id: string;
|
|
219
|
+
}>;
|
|
220
|
+
/** Optional. Delete an M2M client by clientId. */
|
|
221
|
+
deleteM2MClient?(clientId: string): Promise<void>;
|
|
222
|
+
/** Optional. List all M2M clients (without secrets). */
|
|
223
|
+
listM2MClients?(): Promise<M2MClientRecord[]>;
|
|
224
|
+
}
|
|
225
|
+
export interface UserQuery {
|
|
226
|
+
email?: string;
|
|
227
|
+
externalId?: string;
|
|
228
|
+
suspended?: boolean;
|
|
229
|
+
startIndex?: number;
|
|
230
|
+
count?: number;
|
|
231
|
+
}
|
|
232
|
+
export interface UserRecord {
|
|
233
|
+
id: string;
|
|
234
|
+
email?: string;
|
|
235
|
+
displayName?: string;
|
|
236
|
+
firstName?: string;
|
|
237
|
+
lastName?: string;
|
|
238
|
+
externalId?: string;
|
|
239
|
+
suspended: boolean;
|
|
240
|
+
suspendedAt?: Date;
|
|
241
|
+
suspendedReason?: string;
|
|
242
|
+
emailVerified?: boolean;
|
|
243
|
+
providerIds?: string[];
|
|
174
244
|
}
|
|
175
245
|
export declare const setAuthAdapter: (adapter: AuthAdapter) => void;
|
|
176
246
|
export declare const getAuthAdapter: () => AuthAdapter;
|
|
@@ -20,10 +20,34 @@ const memoryStore = {
|
|
|
20
20
|
async delete(key) {
|
|
21
21
|
_memoryStore.delete(key);
|
|
22
22
|
},
|
|
23
|
+
// No increment — memory store uses the read-modify-write fallback (single-process, acceptable)
|
|
23
24
|
};
|
|
24
25
|
// ---------------------------------------------------------------------------
|
|
25
26
|
// Redis implementation
|
|
26
27
|
// ---------------------------------------------------------------------------
|
|
28
|
+
// Lua script: atomically read + increment + write JSON entry, preserving { count, resetAt } format.
|
|
29
|
+
// Returns the new count as a number.
|
|
30
|
+
const TRACK_SCRIPT = `
|
|
31
|
+
local key = KEYS[1]
|
|
32
|
+
local windowMs = tonumber(ARGV[1])
|
|
33
|
+
local now = tonumber(ARGV[2])
|
|
34
|
+
local raw = redis.call("GET", key)
|
|
35
|
+
local count, resetAt
|
|
36
|
+
|
|
37
|
+
if raw then
|
|
38
|
+
local entry = cjson.decode(raw)
|
|
39
|
+
count = entry.count + 1
|
|
40
|
+
resetAt = entry.resetAt
|
|
41
|
+
else
|
|
42
|
+
count = 1
|
|
43
|
+
resetAt = now + windowMs
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
local ttl = math.max(1, resetAt - now)
|
|
47
|
+
local payload = cjson.encode({count = count, resetAt = resetAt})
|
|
48
|
+
redis.call("SET", key, payload, "PX", ttl)
|
|
49
|
+
return count
|
|
50
|
+
`;
|
|
27
51
|
const redisStore = {
|
|
28
52
|
async get(key) {
|
|
29
53
|
const { getRedis } = await import("./redis");
|
|
@@ -43,6 +67,13 @@ const redisStore = {
|
|
|
43
67
|
const { getRedis } = await import("./redis");
|
|
44
68
|
await getRedis().del(`rl:${getAppName()}:${key}`);
|
|
45
69
|
},
|
|
70
|
+
async increment(key, windowMs) {
|
|
71
|
+
const { getRedis } = await import("./redis");
|
|
72
|
+
const fullKey = `rl:${getAppName()}:${key}`;
|
|
73
|
+
const now = Date.now();
|
|
74
|
+
const count = await getRedis().eval(TRACK_SCRIPT, 1, fullKey, windowMs, now);
|
|
75
|
+
return count;
|
|
76
|
+
},
|
|
46
77
|
};
|
|
47
78
|
// ---------------------------------------------------------------------------
|
|
48
79
|
// Active store + setter
|
|
@@ -60,6 +91,11 @@ export const isLimited = async (key, opts) => {
|
|
|
60
91
|
};
|
|
61
92
|
/** Increments the counter and returns true if now over the limit. */
|
|
62
93
|
export const trackAttempt = async (key, opts) => {
|
|
94
|
+
if (_store.increment) {
|
|
95
|
+
const count = await _store.increment(key, opts.windowMs);
|
|
96
|
+
return count >= opts.max;
|
|
97
|
+
}
|
|
98
|
+
// Read-modify-write fallback for memory store (single-process — no lost increments)
|
|
63
99
|
const now = Date.now();
|
|
64
100
|
const existing = await _store.get(key);
|
|
65
101
|
if (!existing) {
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { BreachedPasswordConfig } from "./appConfig";
|
|
2
|
+
export type { BreachedPasswordConfig };
|
|
3
|
+
/**
|
|
4
|
+
* Check whether a password has appeared in a data breach using the
|
|
5
|
+
* HaveIBeenPwned k-Anonymity API.
|
|
6
|
+
*
|
|
7
|
+
* Sends only the first 5 hex chars of the SHA-1 hash — the full hash
|
|
8
|
+
* never leaves the server.
|
|
9
|
+
*/
|
|
10
|
+
export declare function checkBreachedPassword(password: string, config?: BreachedPasswordConfig): Promise<{
|
|
11
|
+
breached: boolean;
|
|
12
|
+
count: number;
|
|
13
|
+
}>;
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Check whether a password has appeared in a data breach using the
|
|
3
|
+
* HaveIBeenPwned k-Anonymity API.
|
|
4
|
+
*
|
|
5
|
+
* Sends only the first 5 hex chars of the SHA-1 hash — the full hash
|
|
6
|
+
* never leaves the server.
|
|
7
|
+
*/
|
|
8
|
+
export async function checkBreachedPassword(password, config) {
|
|
9
|
+
const timeout = config?.timeout ?? 3000;
|
|
10
|
+
const minCount = config?.minBreachCount ?? 1;
|
|
11
|
+
// SHA-1 the password
|
|
12
|
+
const encoder = new TextEncoder();
|
|
13
|
+
const data = encoder.encode(password);
|
|
14
|
+
const hashBuffer = await crypto.subtle.digest("SHA-1", data);
|
|
15
|
+
const hashArray = Array.from(new Uint8Array(hashBuffer));
|
|
16
|
+
const hashHex = hashArray.map((b) => b.toString(16).padStart(2, "0")).join("").toUpperCase();
|
|
17
|
+
const prefix = hashHex.slice(0, 5);
|
|
18
|
+
const suffix = hashHex.slice(5);
|
|
19
|
+
let responseText;
|
|
20
|
+
try {
|
|
21
|
+
const controller = new AbortController();
|
|
22
|
+
const timer = setTimeout(() => controller.abort(), timeout);
|
|
23
|
+
const res = await fetch(`https://api.pwnedpasswords.com/range/${prefix}`, {
|
|
24
|
+
signal: controller.signal,
|
|
25
|
+
headers: { "Add-Padding": "true" },
|
|
26
|
+
});
|
|
27
|
+
clearTimeout(timer);
|
|
28
|
+
if (!res.ok)
|
|
29
|
+
throw new Error(`HIBP API returned ${res.status}`);
|
|
30
|
+
responseText = await res.text();
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
// API failure — apply onApiFailure policy
|
|
34
|
+
if (config?.onApiFailure === "block") {
|
|
35
|
+
return { breached: true, count: -1 };
|
|
36
|
+
}
|
|
37
|
+
return { breached: false, count: 0 };
|
|
38
|
+
}
|
|
39
|
+
// Parse response: each line is "SUFFIX:COUNT"
|
|
40
|
+
for (const line of responseText.split("\n")) {
|
|
41
|
+
const [lineSuffix, countStr] = line.trim().split(":");
|
|
42
|
+
if (lineSuffix === suffix) {
|
|
43
|
+
const count = parseInt(countStr, 10);
|
|
44
|
+
return { breached: count >= minCount, count };
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return { breached: false, count: 0 };
|
|
48
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
export type CaptchaProvider = "recaptcha" | "hcaptcha" | "turnstile";
|
|
2
|
+
export interface CaptchaConfig {
|
|
3
|
+
provider: CaptchaProvider;
|
|
4
|
+
secretKey: string;
|
|
5
|
+
/** Minimum score for reCAPTCHA v3. Default: 0.5. Ignored for v2/hcaptcha/turnstile. */
|
|
6
|
+
minScore?: number;
|
|
7
|
+
/** Body field name containing the CAPTCHA token. Default: "captcha-token". */
|
|
8
|
+
tokenField?: string;
|
|
9
|
+
/** When true, only require CAPTCHA after rate limit threshold is hit. Default: false. */
|
|
10
|
+
adaptive?: boolean;
|
|
11
|
+
/** Rate limit window for adaptive mode. */
|
|
12
|
+
adaptiveThreshold?: {
|
|
13
|
+
windowMs: number;
|
|
14
|
+
max: number;
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Verify a CAPTCHA token with the provider's API.
|
|
19
|
+
* Returns { success: true } on pass, { success: false, error } on fail.
|
|
20
|
+
*/
|
|
21
|
+
export declare function verifyCaptcha(token: string, config: CaptchaConfig, ip?: string): Promise<{
|
|
22
|
+
success: boolean;
|
|
23
|
+
score?: number;
|
|
24
|
+
error?: string;
|
|
25
|
+
}>;
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
const VERIFY_URLS = {
|
|
2
|
+
recaptcha: "https://www.google.com/recaptcha/api/siteverify",
|
|
3
|
+
hcaptcha: "https://hcaptcha.com/siteverify",
|
|
4
|
+
turnstile: "https://challenges.cloudflare.com/turnstile/v0/siteverify",
|
|
5
|
+
};
|
|
6
|
+
/**
|
|
7
|
+
* Verify a CAPTCHA token with the provider's API.
|
|
8
|
+
* Returns { success: true } on pass, { success: false, error } on fail.
|
|
9
|
+
*/
|
|
10
|
+
export async function verifyCaptcha(token, config, ip) {
|
|
11
|
+
const url = VERIFY_URLS[config.provider];
|
|
12
|
+
const body = new URLSearchParams({ secret: config.secretKey, response: token });
|
|
13
|
+
if (ip)
|
|
14
|
+
body.set("remoteip", ip);
|
|
15
|
+
let data;
|
|
16
|
+
try {
|
|
17
|
+
const res = await fetch(url, { method: "POST", body });
|
|
18
|
+
if (!res.ok)
|
|
19
|
+
return { success: false, error: `Provider returned ${res.status}` };
|
|
20
|
+
data = await res.json();
|
|
21
|
+
}
|
|
22
|
+
catch (err) {
|
|
23
|
+
return { success: false, error: "CAPTCHA provider unreachable" };
|
|
24
|
+
}
|
|
25
|
+
if (!data.success) {
|
|
26
|
+
return { success: false, error: String(data["error-codes"]?.[0] ?? "invalid-token") };
|
|
27
|
+
}
|
|
28
|
+
// reCAPTCHA v3: check score
|
|
29
|
+
if (config.provider === "recaptcha" && typeof data.score === "number") {
|
|
30
|
+
const minScore = config.minScore ?? 0.5;
|
|
31
|
+
if (data.score < minScore) {
|
|
32
|
+
return { success: false, score: data.score, error: "score-too-low" };
|
|
33
|
+
}
|
|
34
|
+
return { success: true, score: data.score };
|
|
35
|
+
}
|
|
36
|
+
return { success: true };
|
|
37
|
+
}
|
package/dist/lib/context.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { OpenAPIHono, type Hook } from "@hono/zod-openapi";
|
|
2
2
|
import type { ZodIssue } from "zod";
|
|
3
|
+
import type { JWTPayload } from "jose";
|
|
3
4
|
import type { UploadResult } from "./storageAdapter";
|
|
4
5
|
export interface ValidationErrorDetail {
|
|
5
6
|
path: string;
|
|
@@ -22,6 +23,10 @@ export type AppVariables = {
|
|
|
22
23
|
validationErrorFormatter: ValidationErrorFormatter;
|
|
23
24
|
uploadResults: UploadResult[] | null;
|
|
24
25
|
uploadBucket: string | undefined;
|
|
26
|
+
/** Set by identify when a scope-bearing M2M token (no sid) is verified. */
|
|
27
|
+
authClientId: string | null;
|
|
28
|
+
/** Raw verified JWT payload stashed by identify for downstream middleware. Null when unauthenticated. */
|
|
29
|
+
tokenPayload: JWTPayload | null;
|
|
25
30
|
};
|
|
26
31
|
export type AppEnv = {
|
|
27
32
|
Variables: AppVariables;
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
export interface CredentialStuffingConfig {
|
|
2
|
+
/** Block when an IP attempts login against this many distinct accounts. Default: 5 per 15 min. */
|
|
3
|
+
maxAccountsPerIp?: {
|
|
4
|
+
count: number;
|
|
5
|
+
windowMs: number;
|
|
6
|
+
};
|
|
7
|
+
/** Block when an account is attempted from this many distinct IPs. Default: 10 per 15 min. */
|
|
8
|
+
maxIpsPerAccount?: {
|
|
9
|
+
count: number;
|
|
10
|
+
windowMs: number;
|
|
11
|
+
};
|
|
12
|
+
/** Called when stuffing is detected. Non-blocking, errors swallowed. */
|
|
13
|
+
onDetected?: (signal: {
|
|
14
|
+
type: "ip" | "account";
|
|
15
|
+
key: string;
|
|
16
|
+
count: number;
|
|
17
|
+
}) => void;
|
|
18
|
+
}
|
|
19
|
+
export declare function setCredentialStuffingConfig(config: CredentialStuffingConfig | null): void;
|
|
20
|
+
export declare function getCredentialStuffingConfig(): CredentialStuffingConfig | null;
|
|
21
|
+
/**
|
|
22
|
+
* Track a failed login attempt. Call this AFTER confirming the login failed.
|
|
23
|
+
*/
|
|
24
|
+
export declare function trackFailedLogin(ip: string, identifier: string): void;
|
|
25
|
+
/**
|
|
26
|
+
* Check whether this login attempt should be blocked.
|
|
27
|
+
* Call this BEFORE verifying credentials.
|
|
28
|
+
*/
|
|
29
|
+
export declare function isStuffingBlocked(ip: string, identifier: string): boolean;
|
|
30
|
+
/** Clear the in-memory store (for testing). */
|
|
31
|
+
export declare function clearCredentialStuffingStore(): void;
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
// In-memory store: key → BoundedSet
|
|
2
|
+
const _store = new Map();
|
|
3
|
+
function cleanExpired() {
|
|
4
|
+
const now = Date.now();
|
|
5
|
+
for (const [key, entry] of _store.entries()) {
|
|
6
|
+
if (entry.expiresAt < now)
|
|
7
|
+
_store.delete(key);
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
function addToSet(key, member, windowMs) {
|
|
11
|
+
cleanExpired();
|
|
12
|
+
const now = Date.now();
|
|
13
|
+
let entry = _store.get(key);
|
|
14
|
+
if (!entry || entry.expiresAt < now) {
|
|
15
|
+
entry = { members: new Set(), expiresAt: now + windowMs };
|
|
16
|
+
_store.set(key, entry);
|
|
17
|
+
}
|
|
18
|
+
entry.members.add(member);
|
|
19
|
+
return entry.members.size;
|
|
20
|
+
}
|
|
21
|
+
function getSetSize(key) {
|
|
22
|
+
cleanExpired();
|
|
23
|
+
const now = Date.now();
|
|
24
|
+
const entry = _store.get(key);
|
|
25
|
+
if (!entry || entry.expiresAt < now)
|
|
26
|
+
return 0;
|
|
27
|
+
return entry.members.size;
|
|
28
|
+
}
|
|
29
|
+
let _config = null;
|
|
30
|
+
export function setCredentialStuffingConfig(config) {
|
|
31
|
+
_config = config;
|
|
32
|
+
}
|
|
33
|
+
export function getCredentialStuffingConfig() {
|
|
34
|
+
return _config;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Track a failed login attempt. Call this AFTER confirming the login failed.
|
|
38
|
+
*/
|
|
39
|
+
export function trackFailedLogin(ip, identifier) {
|
|
40
|
+
if (!_config)
|
|
41
|
+
return;
|
|
42
|
+
const ipWindowMs = _config.maxAccountsPerIp?.windowMs ?? 15 * 60 * 1000;
|
|
43
|
+
const accountWindowMs = _config.maxIpsPerAccount?.windowMs ?? 15 * 60 * 1000;
|
|
44
|
+
addToSet(`ip:${ip}`, identifier, ipWindowMs);
|
|
45
|
+
addToSet(`account:${identifier}`, ip, accountWindowMs);
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Check whether this login attempt should be blocked.
|
|
49
|
+
* Call this BEFORE verifying credentials.
|
|
50
|
+
*/
|
|
51
|
+
export function isStuffingBlocked(ip, identifier) {
|
|
52
|
+
if (!_config)
|
|
53
|
+
return false;
|
|
54
|
+
const ipMax = _config.maxAccountsPerIp?.count ?? 5;
|
|
55
|
+
const accountMax = _config.maxIpsPerAccount?.count ?? 10;
|
|
56
|
+
const ipCount = getSetSize(`ip:${ip}`);
|
|
57
|
+
if (ipCount >= ipMax) {
|
|
58
|
+
try {
|
|
59
|
+
_config.onDetected?.({ type: "ip", key: ip, count: ipCount });
|
|
60
|
+
}
|
|
61
|
+
catch { /* swallow */ }
|
|
62
|
+
return true;
|
|
63
|
+
}
|
|
64
|
+
const accountCount = getSetSize(`account:${identifier}`);
|
|
65
|
+
if (accountCount >= accountMax) {
|
|
66
|
+
try {
|
|
67
|
+
_config.onDetected?.({ type: "account", key: identifier, count: accountCount });
|
|
68
|
+
}
|
|
69
|
+
catch { /* swallow */ }
|
|
70
|
+
return true;
|
|
71
|
+
}
|
|
72
|
+
return false;
|
|
73
|
+
}
|
|
74
|
+
/** Clear the in-memory store (for testing). */
|
|
75
|
+
export function clearCredentialStuffingStore() {
|
|
76
|
+
_store.clear();
|
|
77
|
+
}
|
|
@@ -10,4 +10,10 @@ export declare const getVerificationToken: (token: string) => Promise<{
|
|
|
10
10
|
} | null>;
|
|
11
11
|
/** Delete a verification token by its raw value. Hashes before lookup. */
|
|
12
12
|
export declare const deleteVerificationToken: (token: string) => Promise<void>;
|
|
13
|
+
/** Atomically consume a verification token — returns its payload and deletes it in one operation.
|
|
14
|
+
* Returns null if the token is invalid, expired, or already used. */
|
|
15
|
+
export declare const consumeVerificationToken: (token: string) => Promise<{
|
|
16
|
+
userId: string;
|
|
17
|
+
email: string;
|
|
18
|
+
} | null>;
|
|
13
19
|
export {};
|