@lastshotlabs/bunshot 0.0.21 → 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/README.md +3035 -1249
- package/dist/adapters/localStorage.d.ts +6 -0
- package/dist/adapters/localStorage.js +59 -0
- package/dist/adapters/memoryAuth.d.ts +13 -0
- package/dist/adapters/memoryAuth.js +261 -2
- package/dist/adapters/memoryStorage.d.ts +3 -0
- package/dist/adapters/memoryStorage.js +44 -0
- package/dist/adapters/mongoAuth.js +217 -1
- package/dist/adapters/s3Storage.d.ts +14 -0
- package/dist/adapters/s3Storage.js +126 -0
- package/dist/adapters/sqliteAuth.d.ts +30 -0
- package/dist/adapters/sqliteAuth.js +352 -2
- package/dist/app.d.ts +203 -3
- package/dist/app.js +352 -48
- package/dist/cli.js +118 -38
- package/dist/index.d.ts +69 -8
- package/dist/index.js +46 -5
- package/dist/lib/HttpError.d.ts +7 -1
- package/dist/lib/HttpError.js +10 -1
- package/dist/lib/appConfig.d.ts +157 -0
- package/dist/lib/appConfig.js +54 -0
- package/dist/lib/auditLog.d.ts +58 -0
- package/dist/lib/auditLog.js +218 -0
- package/dist/lib/authAdapter.d.ts +140 -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/constants.d.ts +4 -0
- package/dist/lib/constants.js +4 -0
- package/dist/lib/context.d.ts +24 -1
- package/dist/lib/context.js +17 -3
- package/dist/lib/createRoute.d.ts +28 -2
- package/dist/lib/createRoute.js +54 -3
- package/dist/lib/credentialStuffing.d.ts +31 -0
- package/dist/lib/credentialStuffing.js +77 -0
- package/dist/lib/deletionCancelToken.d.ts +12 -0
- package/dist/lib/deletionCancelToken.js +88 -0
- package/dist/lib/emailVerification.d.ts +6 -0
- package/dist/lib/emailVerification.js +46 -3
- package/dist/lib/groups.d.ts +113 -0
- package/dist/lib/groups.js +133 -0
- package/dist/lib/idempotency.d.ts +22 -0
- package/dist/lib/idempotency.js +182 -0
- 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/metrics.d.ts +14 -0
- package/dist/lib/metrics.js +158 -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/pagination.d.ts +119 -0
- package/dist/lib/pagination.js +166 -0
- 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 +14 -0
- package/dist/lib/session.js +121 -5
- package/dist/lib/signing.d.ts +52 -0
- package/dist/lib/signing.js +183 -0
- package/dist/lib/storageAdapter.d.ts +30 -0
- package/dist/lib/storageAdapter.js +1 -0
- package/dist/lib/stripUnreferencedSchemas.d.ts +11 -0
- package/dist/lib/stripUnreferencedSchemas.js +79 -0
- package/dist/lib/suspension.d.ts +13 -0
- package/dist/lib/suspension.js +23 -0
- package/dist/lib/tenant.js +2 -2
- package/dist/lib/upload.d.ts +39 -0
- package/dist/lib/upload.js +112 -0
- package/dist/lib/uploadRegistry.d.ts +18 -0
- package/dist/lib/uploadRegistry.js +83 -0
- package/dist/lib/validate.js +2 -2
- package/dist/lib/ws.d.ts +1 -0
- package/dist/lib/ws.js +28 -0
- package/dist/lib/wsHeartbeat.d.ts +12 -0
- package/dist/lib/wsHeartbeat.js +57 -0
- package/dist/lib/wsMessages.d.ts +40 -0
- package/dist/lib/wsMessages.js +330 -0
- package/dist/lib/wsPresence.d.ts +25 -0
- package/dist/lib/wsPresence.js +99 -0
- package/dist/middleware/auditLog.d.ts +22 -0
- package/dist/middleware/auditLog.js +39 -0
- package/dist/middleware/bearerAuth.js +1 -1
- package/dist/middleware/cacheResponse.js +5 -1
- package/dist/middleware/captcha.d.ts +10 -0
- package/dist/middleware/captcha.js +36 -0
- package/dist/middleware/csrf.js +18 -4
- package/dist/middleware/errorHandler.js +4 -1
- package/dist/middleware/identify.js +89 -14
- package/dist/middleware/metrics.d.ts +9 -0
- package/dist/middleware/metrics.js +26 -0
- package/dist/middleware/requestId.d.ts +3 -0
- package/dist/middleware/requestId.js +7 -0
- package/dist/middleware/requestLogger.d.ts +38 -0
- package/dist/middleware/requestLogger.js +68 -0
- package/dist/middleware/requestSigning.d.ts +20 -0
- package/dist/middleware/requestSigning.js +100 -0
- package/dist/middleware/requireMfaSetup.d.ts +16 -0
- package/dist/middleware/requireMfaSetup.js +37 -0
- package/dist/middleware/requireRole.d.ts +9 -3
- package/dist/middleware/requireRole.js +23 -36
- 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/upload.d.ts +5 -0
- package/dist/middleware/upload.js +27 -0
- package/dist/middleware/webhookAuth.d.ts +30 -0
- package/dist/middleware/webhookAuth.js +58 -0
- package/dist/models/AuditLog.d.ts +30 -0
- package/dist/models/AuditLog.js +39 -0
- package/dist/models/AuthUser.d.ts +7 -0
- package/dist/models/AuthUser.js +7 -0
- package/dist/models/Group.d.ts +21 -0
- package/dist/models/Group.js +28 -0
- package/dist/models/GroupMembership.d.ts +21 -0
- package/dist/models/GroupMembership.js +25 -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 +238 -21
- package/dist/routes/groups.d.ts +21 -0
- package/dist/routes/groups.js +346 -0
- package/dist/routes/jobs.js +66 -46
- package/dist/routes/m2m.d.ts +2 -0
- package/dist/routes/m2m.js +72 -0
- package/dist/routes/metrics.d.ts +8 -0
- package/dist/routes/metrics.js +55 -0
- package/dist/routes/mfa.js +13 -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 +14 -0
- package/dist/routes/uploads.js +227 -0
- package/dist/server.d.ts +26 -0
- package/dist/server.js +46 -3
- 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 +5 -1
- package/docs/sections/auth-flow/full.md +203 -47
- package/docs/sections/auth-flow/overview.md +2 -2
- package/docs/sections/auth-security-examples/full.md +388 -0
- package/docs/sections/authentication/full.md +130 -0
- package/docs/sections/authentication/overview.md +5 -0
- package/docs/sections/cli/full.md +13 -1
- package/docs/sections/configuration/full.md +17 -0
- package/docs/sections/configuration/overview.md +1 -0
- package/docs/sections/exports/full.md +34 -3
- package/docs/sections/logging/full.md +83 -0
- package/docs/sections/metrics/full.md +131 -0
- package/docs/sections/oauth/full.md +189 -189
- package/docs/sections/oauth/overview.md +1 -1
- package/docs/sections/pagination/full.md +93 -0
- package/docs/sections/passkey-login/full.md +90 -0
- package/docs/sections/passkey-login/overview.md +1 -0
- package/docs/sections/roles/full.md +224 -135
- package/docs/sections/roles/overview.md +3 -1
- package/docs/sections/signing/full.md +203 -0
- package/docs/sections/uploads/full.md +208 -0
- package/docs/sections/versioning/full.md +85 -0
- package/docs/sections/webhook-auth/full.md +100 -0
- package/docs/sections/websocket/full.md +95 -0
- package/docs/sections/websocket-rooms/full.md +6 -1
- package/package.json +18 -5
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import type { ZodType } from "zod";
|
|
3
|
+
export type { PaginationOpts, PaginatedResult } from "./groups";
|
|
4
|
+
export interface OffsetParamDefaults {
|
|
5
|
+
/** Default: 50 */
|
|
6
|
+
limit?: number;
|
|
7
|
+
/** Default: 200 */
|
|
8
|
+
maxLimit?: number;
|
|
9
|
+
/** Default: 0 */
|
|
10
|
+
offset?: number;
|
|
11
|
+
}
|
|
12
|
+
export interface ParsedOffsetParams {
|
|
13
|
+
limit: number;
|
|
14
|
+
offset: number;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Zod schema for offset pagination query params.
|
|
18
|
+
* Fields are `z.string().optional()` — matches the existing query param
|
|
19
|
+
* convention. Parse the values with `parseOffsetParams`.
|
|
20
|
+
*
|
|
21
|
+
* @example
|
|
22
|
+
* createRoute({ ..., request: { query: offsetParams({ limit: 20 }) }, ... })
|
|
23
|
+
*/
|
|
24
|
+
export declare function offsetParams(defaults?: OffsetParamDefaults): z.ZodObject<{
|
|
25
|
+
limit: z.ZodOptional<z.ZodString>;
|
|
26
|
+
offset: z.ZodOptional<z.ZodString>;
|
|
27
|
+
}, z.core.$strip>;
|
|
28
|
+
/**
|
|
29
|
+
* Parses raw string query values into clamped integers.
|
|
30
|
+
* - NaN (non-numeric strings) falls back to defaults
|
|
31
|
+
* - limit clamped to [1, maxLimit]
|
|
32
|
+
* - offset clamped to [0, ∞)
|
|
33
|
+
*
|
|
34
|
+
* @example
|
|
35
|
+
* const { limit, offset } = parseOffsetParams(c.req.query(), { maxLimit: 100 });
|
|
36
|
+
*/
|
|
37
|
+
export declare function parseOffsetParams(raw: {
|
|
38
|
+
limit?: string;
|
|
39
|
+
offset?: string;
|
|
40
|
+
}, defaults?: OffsetParamDefaults): ParsedOffsetParams;
|
|
41
|
+
/**
|
|
42
|
+
* Zod schema factory for paginated offset responses.
|
|
43
|
+
* Wraps `itemSchema` in `{ items, total, limit, offset }` and registers
|
|
44
|
+
* the result as a named OpenAPI component.
|
|
45
|
+
*
|
|
46
|
+
* Throws if `name` was previously registered to a different schema instance.
|
|
47
|
+
* Calling with the same `name` + `schema` pair is idempotent.
|
|
48
|
+
*
|
|
49
|
+
* @example
|
|
50
|
+
* const PaginatedUsersResponse = paginatedResponse(UserSchema, "PaginatedUsers");
|
|
51
|
+
*/
|
|
52
|
+
export declare function paginatedResponse<T extends ZodType>(itemSchema: T, name: string): z.ZodObject<{
|
|
53
|
+
items: z.ZodArray<T>;
|
|
54
|
+
total: z.ZodNumber;
|
|
55
|
+
limit: z.ZodNumber;
|
|
56
|
+
offset: z.ZodNumber;
|
|
57
|
+
}, z.core.$strip>;
|
|
58
|
+
export interface CursorParamDefaults {
|
|
59
|
+
/** Default: 50 */
|
|
60
|
+
limit?: number;
|
|
61
|
+
/** Default: 200 */
|
|
62
|
+
maxLimit?: number;
|
|
63
|
+
}
|
|
64
|
+
export interface ParsedCursorParams {
|
|
65
|
+
limit: number;
|
|
66
|
+
cursor: string | undefined;
|
|
67
|
+
}
|
|
68
|
+
export interface CursorResult<T> {
|
|
69
|
+
items: T[];
|
|
70
|
+
nextCursor: string | null;
|
|
71
|
+
hasMore: boolean;
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Zod schema for cursor pagination query params.
|
|
75
|
+
* Fields are `z.string().optional()`. Parse the values with `parseCursorParams`.
|
|
76
|
+
*
|
|
77
|
+
* @example
|
|
78
|
+
* createRoute({ ..., request: { query: cursorParams() }, ... })
|
|
79
|
+
*/
|
|
80
|
+
export declare function cursorParams(defaults?: CursorParamDefaults): z.ZodObject<{
|
|
81
|
+
limit: z.ZodOptional<z.ZodString>;
|
|
82
|
+
cursor: z.ZodOptional<z.ZodString>;
|
|
83
|
+
}, z.core.$strip>;
|
|
84
|
+
/**
|
|
85
|
+
* Parses raw string query values into typed cursor params.
|
|
86
|
+
* - limit: NaN falls back to default, clamped to [1, maxLimit]
|
|
87
|
+
* - cursor: empty string normalized to `undefined`; non-empty is pass-through
|
|
88
|
+
* - When `signing.cursors: true`, verifies the cursor HMAC — invalid cursor returns null
|
|
89
|
+
*
|
|
90
|
+
* @example
|
|
91
|
+
* const { limit, cursor } = parseCursorParams(c.req.query());
|
|
92
|
+
*/
|
|
93
|
+
export declare function parseCursorParams(raw: {
|
|
94
|
+
limit?: string;
|
|
95
|
+
cursor?: string;
|
|
96
|
+
}, defaults?: CursorParamDefaults): ParsedCursorParams & {
|
|
97
|
+
invalidCursor?: true;
|
|
98
|
+
};
|
|
99
|
+
/**
|
|
100
|
+
* Sign a cursor value if `signing.cursors: true`. Otherwise returns the
|
|
101
|
+
* cursor unchanged (current behavior).
|
|
102
|
+
*/
|
|
103
|
+
export declare function maybeSignCursor(cursor: string | null): string | null;
|
|
104
|
+
/**
|
|
105
|
+
* Zod schema factory for cursor-paginated responses.
|
|
106
|
+
* Wraps `itemSchema` in `{ items, nextCursor, hasMore }` and registers
|
|
107
|
+
* the result as a named OpenAPI component.
|
|
108
|
+
*
|
|
109
|
+
* Throws if `name` was previously registered to a different schema instance.
|
|
110
|
+
* Calling with the same `name` + `schema` pair is idempotent.
|
|
111
|
+
*
|
|
112
|
+
* @example
|
|
113
|
+
* const PostsPage = cursorResponse(PostSchema, "PostsPage");
|
|
114
|
+
*/
|
|
115
|
+
export declare function cursorResponse<T extends ZodType>(itemSchema: T, name: string): z.ZodObject<{
|
|
116
|
+
items: z.ZodArray<T>;
|
|
117
|
+
nextCursor: z.ZodNullable<z.ZodString>;
|
|
118
|
+
hasMore: z.ZodBoolean;
|
|
119
|
+
}, z.core.$strip>;
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { registerSchema } from "./createRoute";
|
|
3
|
+
import { getSigningConfig, getSigningSecret } from "./appConfig";
|
|
4
|
+
import { signCursor, verifyCursor } from "./signing";
|
|
5
|
+
const _registered = new Map();
|
|
6
|
+
function guardedRegister(name, itemSchema, buildWrapper) {
|
|
7
|
+
const existing = _registered.get(name);
|
|
8
|
+
if (existing !== undefined) {
|
|
9
|
+
if (existing.itemSchema !== itemSchema) {
|
|
10
|
+
throw new Error(`Pagination schema name "${name}" is already registered to a different schema`);
|
|
11
|
+
}
|
|
12
|
+
// Same item schema → idempotent, return the cached wrapper
|
|
13
|
+
return existing.wrapper;
|
|
14
|
+
}
|
|
15
|
+
const wrapper = buildWrapper();
|
|
16
|
+
_registered.set(name, { itemSchema, wrapper });
|
|
17
|
+
registerSchema(name, wrapper);
|
|
18
|
+
return wrapper;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Zod schema for offset pagination query params.
|
|
22
|
+
* Fields are `z.string().optional()` — matches the existing query param
|
|
23
|
+
* convention. Parse the values with `parseOffsetParams`.
|
|
24
|
+
*
|
|
25
|
+
* @example
|
|
26
|
+
* createRoute({ ..., request: { query: offsetParams({ limit: 20 }) }, ... })
|
|
27
|
+
*/
|
|
28
|
+
export function offsetParams(defaults) {
|
|
29
|
+
const defaultLimit = defaults?.limit ?? 50;
|
|
30
|
+
const defaultOffset = defaults?.offset ?? 0;
|
|
31
|
+
const maxLimit = defaults?.maxLimit ?? 200;
|
|
32
|
+
return z.object({
|
|
33
|
+
limit: z
|
|
34
|
+
.string()
|
|
35
|
+
.optional()
|
|
36
|
+
.describe(`Number of items to return (1–${maxLimit}, default ${defaultLimit})`),
|
|
37
|
+
offset: z
|
|
38
|
+
.string()
|
|
39
|
+
.optional()
|
|
40
|
+
.describe(`Number of items to skip (default ${defaultOffset})`),
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Parses raw string query values into clamped integers.
|
|
45
|
+
* - NaN (non-numeric strings) falls back to defaults
|
|
46
|
+
* - limit clamped to [1, maxLimit]
|
|
47
|
+
* - offset clamped to [0, ∞)
|
|
48
|
+
*
|
|
49
|
+
* @example
|
|
50
|
+
* const { limit, offset } = parseOffsetParams(c.req.query(), { maxLimit: 100 });
|
|
51
|
+
*/
|
|
52
|
+
export function parseOffsetParams(raw, defaults) {
|
|
53
|
+
const defaultLimit = defaults?.limit ?? 50;
|
|
54
|
+
const maxLimit = defaults?.maxLimit ?? 200;
|
|
55
|
+
const defaultOffset = defaults?.offset ?? 0;
|
|
56
|
+
const rawLimit = parseInt(raw.limit ?? "", 10);
|
|
57
|
+
const rawOffset = parseInt(raw.offset ?? "", 10);
|
|
58
|
+
const limit = isNaN(rawLimit)
|
|
59
|
+
? defaultLimit
|
|
60
|
+
: Math.min(Math.max(rawLimit, 1), maxLimit);
|
|
61
|
+
const offset = isNaN(rawOffset) ? defaultOffset : Math.max(rawOffset, 0);
|
|
62
|
+
return { limit, offset };
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Zod schema factory for paginated offset responses.
|
|
66
|
+
* Wraps `itemSchema` in `{ items, total, limit, offset }` and registers
|
|
67
|
+
* the result as a named OpenAPI component.
|
|
68
|
+
*
|
|
69
|
+
* Throws if `name` was previously registered to a different schema instance.
|
|
70
|
+
* Calling with the same `name` + `schema` pair is idempotent.
|
|
71
|
+
*
|
|
72
|
+
* @example
|
|
73
|
+
* const PaginatedUsersResponse = paginatedResponse(UserSchema, "PaginatedUsers");
|
|
74
|
+
*/
|
|
75
|
+
export function paginatedResponse(itemSchema, name) {
|
|
76
|
+
return guardedRegister(name, itemSchema, () => z.object({
|
|
77
|
+
items: z.array(itemSchema),
|
|
78
|
+
total: z.number().int().nonnegative(),
|
|
79
|
+
limit: z.number().int().positive(),
|
|
80
|
+
offset: z.number().int().nonnegative(),
|
|
81
|
+
}));
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Zod schema for cursor pagination query params.
|
|
85
|
+
* Fields are `z.string().optional()`. Parse the values with `parseCursorParams`.
|
|
86
|
+
*
|
|
87
|
+
* @example
|
|
88
|
+
* createRoute({ ..., request: { query: cursorParams() }, ... })
|
|
89
|
+
*/
|
|
90
|
+
export function cursorParams(defaults) {
|
|
91
|
+
const defaultLimit = defaults?.limit ?? 50;
|
|
92
|
+
const maxLimit = defaults?.maxLimit ?? 200;
|
|
93
|
+
return z.object({
|
|
94
|
+
limit: z
|
|
95
|
+
.string()
|
|
96
|
+
.optional()
|
|
97
|
+
.describe(`Number of items to return (1–${maxLimit}, default ${defaultLimit})`),
|
|
98
|
+
cursor: z
|
|
99
|
+
.string()
|
|
100
|
+
.optional()
|
|
101
|
+
.describe("Opaque cursor from a previous response's nextCursor field"),
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* Parses raw string query values into typed cursor params.
|
|
106
|
+
* - limit: NaN falls back to default, clamped to [1, maxLimit]
|
|
107
|
+
* - cursor: empty string normalized to `undefined`; non-empty is pass-through
|
|
108
|
+
* - When `signing.cursors: true`, verifies the cursor HMAC — invalid cursor returns null
|
|
109
|
+
*
|
|
110
|
+
* @example
|
|
111
|
+
* const { limit, cursor } = parseCursorParams(c.req.query());
|
|
112
|
+
*/
|
|
113
|
+
export function parseCursorParams(raw, defaults) {
|
|
114
|
+
const defaultLimit = defaults?.limit ?? 50;
|
|
115
|
+
const maxLimit = defaults?.maxLimit ?? 200;
|
|
116
|
+
const rawLimit = parseInt(raw.limit ?? "", 10);
|
|
117
|
+
const limit = isNaN(rawLimit)
|
|
118
|
+
? defaultLimit
|
|
119
|
+
: Math.min(Math.max(rawLimit, 1), maxLimit);
|
|
120
|
+
if (!raw.cursor)
|
|
121
|
+
return { limit, cursor: undefined };
|
|
122
|
+
const cfg = getSigningConfig();
|
|
123
|
+
if (cfg?.cursors) {
|
|
124
|
+
const secret = getSigningSecret();
|
|
125
|
+
if (secret) {
|
|
126
|
+
const verified = verifyCursor(raw.cursor, secret);
|
|
127
|
+
if (verified === null)
|
|
128
|
+
return { limit, cursor: undefined, invalidCursor: true };
|
|
129
|
+
return { limit, cursor: verified };
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
return { limit, cursor: raw.cursor };
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* Sign a cursor value if `signing.cursors: true`. Otherwise returns the
|
|
136
|
+
* cursor unchanged (current behavior).
|
|
137
|
+
*/
|
|
138
|
+
export function maybeSignCursor(cursor) {
|
|
139
|
+
if (!cursor)
|
|
140
|
+
return cursor;
|
|
141
|
+
const cfg = getSigningConfig();
|
|
142
|
+
if (cfg?.cursors) {
|
|
143
|
+
const secret = getSigningSecret();
|
|
144
|
+
if (secret)
|
|
145
|
+
return signCursor(cursor, secret);
|
|
146
|
+
}
|
|
147
|
+
return cursor;
|
|
148
|
+
}
|
|
149
|
+
/**
|
|
150
|
+
* Zod schema factory for cursor-paginated responses.
|
|
151
|
+
* Wraps `itemSchema` in `{ items, nextCursor, hasMore }` and registers
|
|
152
|
+
* the result as a named OpenAPI component.
|
|
153
|
+
*
|
|
154
|
+
* Throws if `name` was previously registered to a different schema instance.
|
|
155
|
+
* Calling with the same `name` + `schema` pair is idempotent.
|
|
156
|
+
*
|
|
157
|
+
* @example
|
|
158
|
+
* const PostsPage = cursorResponse(PostSchema, "PostsPage");
|
|
159
|
+
*/
|
|
160
|
+
export function cursorResponse(itemSchema, name) {
|
|
161
|
+
return guardedRegister(name, itemSchema, () => z.object({
|
|
162
|
+
items: z.array(itemSchema),
|
|
163
|
+
nextCursor: z.string().nullable(),
|
|
164
|
+
hasMore: z.boolean(),
|
|
165
|
+
}));
|
|
166
|
+
}
|
|
@@ -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;
|
package/dist/lib/saml.js
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
let _sp = null;
|
|
2
|
+
let _idp = null;
|
|
3
|
+
export async function initSaml(config) {
|
|
4
|
+
const samlify = await import("samlify");
|
|
5
|
+
_sp = samlify.ServiceProvider({
|
|
6
|
+
entityID: config.entityId,
|
|
7
|
+
assertionConsumerService: [{
|
|
8
|
+
Binding: samlify.Constants.BindingNamespace.Post,
|
|
9
|
+
Location: config.acsUrl,
|
|
10
|
+
}],
|
|
11
|
+
signingCert: config.signingCert,
|
|
12
|
+
privateKey: config.signingKey,
|
|
13
|
+
allowCreate: true,
|
|
14
|
+
});
|
|
15
|
+
// Load IdP metadata
|
|
16
|
+
if (config.idpMetadata.startsWith("http")) {
|
|
17
|
+
// URL — fetch it
|
|
18
|
+
const res = await fetch(config.idpMetadata);
|
|
19
|
+
const xml = await res.text();
|
|
20
|
+
_idp = samlify.IdentityProvider({ metadata: xml });
|
|
21
|
+
}
|
|
22
|
+
else {
|
|
23
|
+
// XML string
|
|
24
|
+
_idp = samlify.IdentityProvider({ metadata: config.idpMetadata });
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
export function createAuthnRequest() {
|
|
28
|
+
if (!_sp || !_idp)
|
|
29
|
+
throw new Error("SAML not initialized");
|
|
30
|
+
const { context, entityEndpoint } = _sp.createLoginRequest(_idp, "redirect");
|
|
31
|
+
return { redirectUrl: entityEndpoint + "?" + context, id: context };
|
|
32
|
+
}
|
|
33
|
+
export async function validateSamlResponse(body, config) {
|
|
34
|
+
if (!_sp || !_idp)
|
|
35
|
+
throw new Error("SAML not initialized");
|
|
36
|
+
const { extract } = await _sp.parseLoginResponse(_idp, "post", { body: { SAMLResponse: body } });
|
|
37
|
+
const mapping = config.attributeMapping ?? {};
|
|
38
|
+
const attrs = extract.attributes ?? {};
|
|
39
|
+
const emailKey = mapping.email ?? "email";
|
|
40
|
+
const firstNameKey = mapping.firstName ?? "firstName";
|
|
41
|
+
const lastNameKey = mapping.lastName ?? "lastName";
|
|
42
|
+
const groupsKey = mapping.groups ?? "groups";
|
|
43
|
+
const nameId = extract.nameID;
|
|
44
|
+
const email = attrs[emailKey] ?? nameId;
|
|
45
|
+
const firstName = attrs[firstNameKey];
|
|
46
|
+
const lastName = attrs[lastNameKey];
|
|
47
|
+
const displayName = firstName && lastName ? `${firstName} ${lastName}` : undefined;
|
|
48
|
+
const rawGroups = attrs[groupsKey];
|
|
49
|
+
const groups = rawGroups ? (Array.isArray(rawGroups) ? rawGroups : [rawGroups]) : undefined;
|
|
50
|
+
return { nameId, email, firstName, lastName, displayName, groups, attributes: attrs };
|
|
51
|
+
}
|
|
52
|
+
export function samlProfileToIdentityProfile(profile) {
|
|
53
|
+
return {
|
|
54
|
+
email: profile.email,
|
|
55
|
+
displayName: profile.displayName,
|
|
56
|
+
firstName: profile.firstName,
|
|
57
|
+
lastName: profile.lastName,
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
export function getSamlSpMetadata() {
|
|
61
|
+
if (!_sp)
|
|
62
|
+
throw new Error("SAML not initialized");
|
|
63
|
+
return _sp.getMetadata();
|
|
64
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import type { UserRecord } from "./authAdapter";
|
|
2
|
+
export interface ScimUser {
|
|
3
|
+
schemas: ["urn:ietf:params:scim:schemas:core:2.0:User"];
|
|
4
|
+
id: string;
|
|
5
|
+
externalId?: string;
|
|
6
|
+
userName: string;
|
|
7
|
+
displayName?: string;
|
|
8
|
+
name?: {
|
|
9
|
+
givenName?: string;
|
|
10
|
+
familyName?: string;
|
|
11
|
+
formatted?: string;
|
|
12
|
+
};
|
|
13
|
+
emails?: Array<{
|
|
14
|
+
value: string;
|
|
15
|
+
primary: boolean;
|
|
16
|
+
}>;
|
|
17
|
+
active: boolean;
|
|
18
|
+
meta: {
|
|
19
|
+
resourceType: "User";
|
|
20
|
+
created?: string;
|
|
21
|
+
lastModified?: string;
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
export interface ScimListResponse {
|
|
25
|
+
schemas: ["urn:ietf:params:scim:api:messages:2.0:ListResponse"];
|
|
26
|
+
totalResults: number;
|
|
27
|
+
startIndex: number;
|
|
28
|
+
itemsPerPage: number;
|
|
29
|
+
Resources: ScimUser[];
|
|
30
|
+
}
|
|
31
|
+
export interface ScimError {
|
|
32
|
+
schemas: ["urn:ietf:params:scim:api:messages:2.0:Error"];
|
|
33
|
+
status: string;
|
|
34
|
+
detail: string;
|
|
35
|
+
}
|
|
36
|
+
export declare function userRecordToScim(user: UserRecord, config?: {
|
|
37
|
+
userName?: "email" | "username";
|
|
38
|
+
}): ScimUser;
|
|
39
|
+
/**
|
|
40
|
+
* Parse a simple SCIM filter string into a UserQuery object.
|
|
41
|
+
* Supports: userName eq "val", email eq "val", externalId eq "val", active eq true/false
|
|
42
|
+
*/
|
|
43
|
+
export declare function parseScimFilter(filter?: string): import("./authAdapter").UserQuery;
|
|
44
|
+
export declare function scimError(status: number, detail: string): Response;
|
package/dist/lib/scim.js
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
export function userRecordToScim(user, config) {
|
|
2
|
+
const userName = user.email ?? user.id;
|
|
3
|
+
return {
|
|
4
|
+
schemas: ["urn:ietf:params:scim:schemas:core:2.0:User"],
|
|
5
|
+
id: user.id,
|
|
6
|
+
externalId: user.externalId,
|
|
7
|
+
userName,
|
|
8
|
+
displayName: user.displayName,
|
|
9
|
+
name: (user.firstName || user.lastName) ? {
|
|
10
|
+
givenName: user.firstName,
|
|
11
|
+
familyName: user.lastName,
|
|
12
|
+
formatted: [user.firstName, user.lastName].filter(Boolean).join(" ") || undefined,
|
|
13
|
+
} : undefined,
|
|
14
|
+
emails: user.email ? [{ value: user.email, primary: true }] : undefined,
|
|
15
|
+
active: !user.suspended,
|
|
16
|
+
meta: { resourceType: "User" },
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Parse a simple SCIM filter string into a UserQuery object.
|
|
21
|
+
* Supports: userName eq "val", email eq "val", externalId eq "val", active eq true/false
|
|
22
|
+
*/
|
|
23
|
+
export function parseScimFilter(filter) {
|
|
24
|
+
if (!filter)
|
|
25
|
+
return {};
|
|
26
|
+
const query = {};
|
|
27
|
+
// Simple single-clause filter: `attr op "value"`
|
|
28
|
+
const match = filter.trim().match(/^(\w+)\s+eq\s+"?([^"]*)"?$/i);
|
|
29
|
+
if (!match)
|
|
30
|
+
return {};
|
|
31
|
+
const [, attr, value] = match;
|
|
32
|
+
const attrLower = attr.toLowerCase();
|
|
33
|
+
if (attrLower === "username" || attrLower === "email") {
|
|
34
|
+
query.email = value;
|
|
35
|
+
}
|
|
36
|
+
else if (attrLower === "externalid") {
|
|
37
|
+
query.externalId = value;
|
|
38
|
+
}
|
|
39
|
+
else if (attrLower === "active") {
|
|
40
|
+
query.suspended = value.toLowerCase() !== "true"; // active=true means suspended=false
|
|
41
|
+
}
|
|
42
|
+
return query;
|
|
43
|
+
}
|
|
44
|
+
export function scimError(status, detail) {
|
|
45
|
+
const body = {
|
|
46
|
+
schemas: ["urn:ietf:params:scim:api:messages:2.0:Error"],
|
|
47
|
+
status: String(status),
|
|
48
|
+
detail,
|
|
49
|
+
};
|
|
50
|
+
return new Response(JSON.stringify(body), {
|
|
51
|
+
status,
|
|
52
|
+
headers: { "Content-Type": "application/scim+json" },
|
|
53
|
+
});
|
|
54
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
export type SecurityEventType = "auth.login.success" | "auth.login.failure" | "auth.login.blocked" | "auth.register.success" | "auth.register.failure" | "auth.logout" | "auth.password.reset" | "auth.password.change" | "auth.mfa.setup" | "auth.mfa.verify.success" | "auth.mfa.verify.failure" | "auth.step_up.success" | "auth.step_up.failure" | "auth.session.created" | "auth.session.revoked" | "auth.account.suspended" | "auth.account.deleted" | "auth.oauth.link" | "auth.oauth.unlink" | "security.rate_limit.exceeded" | "security.credential_stuffing.detected" | "security.captcha.failed" | "security.csrf.failed" | "security.breached_password.detected" | "admin.role.changed" | "admin.user.modified";
|
|
2
|
+
export interface SecurityEvent {
|
|
3
|
+
eventType: SecurityEventType;
|
|
4
|
+
severity: "info" | "warn" | "critical";
|
|
5
|
+
timestamp: string;
|
|
6
|
+
requestId?: string;
|
|
7
|
+
userId?: string;
|
|
8
|
+
sessionId?: string;
|
|
9
|
+
tenantId?: string;
|
|
10
|
+
ip?: string;
|
|
11
|
+
userAgent?: string;
|
|
12
|
+
meta?: Record<string, unknown>;
|
|
13
|
+
}
|
|
14
|
+
export interface SecurityEventConfig {
|
|
15
|
+
/** Called for each security event. Non-blocking — errors are swallowed. */
|
|
16
|
+
onEvent: (event: SecurityEvent) => void | Promise<void>;
|
|
17
|
+
/** Only emit events of these types. If omitted, all events are emitted. */
|
|
18
|
+
include?: SecurityEventType[];
|
|
19
|
+
/** Skip events of these types. Applied after include. */
|
|
20
|
+
exclude?: SecurityEventType[];
|
|
21
|
+
}
|
|
22
|
+
export declare function setSecurityEventConfig(config: SecurityEventConfig | null): void;
|
|
23
|
+
export declare function getSecurityEventConfig(): SecurityEventConfig | null;
|
|
24
|
+
/**
|
|
25
|
+
* Emit a security event. Non-blocking — the call returns immediately.
|
|
26
|
+
* If onEvent throws, the error is silently swallowed (never propagates to the caller).
|
|
27
|
+
*/
|
|
28
|
+
export declare function emitSecurityEvent(event: SecurityEvent): void;
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
let _config = null;
|
|
2
|
+
export function setSecurityEventConfig(config) {
|
|
3
|
+
_config = config;
|
|
4
|
+
}
|
|
5
|
+
export function getSecurityEventConfig() {
|
|
6
|
+
return _config;
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* Emit a security event. Non-blocking — the call returns immediately.
|
|
10
|
+
* If onEvent throws, the error is silently swallowed (never propagates to the caller).
|
|
11
|
+
*/
|
|
12
|
+
export function emitSecurityEvent(event) {
|
|
13
|
+
if (!_config)
|
|
14
|
+
return;
|
|
15
|
+
const { onEvent, include, exclude } = _config;
|
|
16
|
+
if (include && include.length > 0 && !include.includes(event.eventType))
|
|
17
|
+
return;
|
|
18
|
+
if (exclude && exclude.includes(event.eventType))
|
|
19
|
+
return;
|
|
20
|
+
// Fire and forget — never block the request
|
|
21
|
+
Promise.resolve()
|
|
22
|
+
.then(() => onEvent({ ...event, timestamp: event.timestamp ?? new Date().toISOString() }))
|
|
23
|
+
.catch(() => {
|
|
24
|
+
// Swallow errors — security event emission must never crash the app
|
|
25
|
+
});
|
|
26
|
+
}
|
package/dist/lib/session.d.ts
CHANGED
|
@@ -32,4 +32,18 @@ export declare const setRefreshToken: (sessionId: string, refreshToken: string)
|
|
|
32
32
|
export declare const getSessionByRefreshToken: (refreshToken: string) => Promise<RefreshResult | null>;
|
|
33
33
|
/** Rotate the refresh token: move current to prev with grace window, set new token + access token. */
|
|
34
34
|
export declare const rotateRefreshToken: (sessionId: string, newRefreshToken: string, newAccessToken: string) => Promise<void>;
|
|
35
|
+
/** Read the stored fingerprint for a session. Returns null if not yet set. */
|
|
36
|
+
export declare const getSessionFingerprint: (sessionId: string) => Promise<string | null>;
|
|
37
|
+
/** Store a fingerprint on an existing session. No-op if the session does not exist. */
|
|
38
|
+
export declare const setSessionFingerprint: (sessionId: string, fingerprint: string) => Promise<void>;
|
|
39
|
+
/**
|
|
40
|
+
* Store the timestamp when MFA was last verified in the session metadata.
|
|
41
|
+
* Used by requireStepUp middleware.
|
|
42
|
+
*/
|
|
43
|
+
export declare const setMfaVerifiedAt: (sessionId: string) => Promise<void>;
|
|
44
|
+
/**
|
|
45
|
+
* Get the Unix timestamp (seconds) when MFA was last verified for this session.
|
|
46
|
+
* Returns null if MFA has never been verified or session not found.
|
|
47
|
+
*/
|
|
48
|
+
export declare const getMfaVerifiedAt: (sessionId: string) => Promise<number | null>;
|
|
35
49
|
export {};
|