sentri 1.1.2 → 2.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +268 -448
- package/dist/cli.d.ts +0 -2
- package/dist/cli.js +113 -107
- package/dist/index.d.ts +545 -11
- package/dist/index.js +1 -5
- package/package.json +9 -7
- package/dist/cli.d.ts.map +0 -1
- package/dist/cli.js.map +0 -1
- package/dist/client.d.ts +0 -160
- package/dist/client.d.ts.map +0 -1
- package/dist/client.js +0 -45
- package/dist/client.js.map +0 -1
- package/dist/errors/AuthError.d.ts +0 -99
- package/dist/errors/AuthError.d.ts.map +0 -1
- package/dist/errors/AuthError.js +0 -97
- package/dist/errors/AuthError.js.map +0 -1
- package/dist/index.d.ts.map +0 -1
- package/dist/index.js.map +0 -1
- package/dist/libs/config.d.ts +0 -62
- package/dist/libs/config.d.ts.map +0 -1
- package/dist/libs/config.js +0 -97
- package/dist/libs/config.js.map +0 -1
- package/dist/libs/hash.d.ts +0 -17
- package/dist/libs/hash.d.ts.map +0 -1
- package/dist/libs/hash.js +0 -22
- package/dist/libs/hash.js.map +0 -1
- package/dist/libs/token.d.ts +0 -46
- package/dist/libs/token.d.ts.map +0 -1
- package/dist/libs/token.js +0 -118
- package/dist/libs/token.js.map +0 -1
- package/dist/middleware/authorize.d.ts +0 -18
- package/dist/middleware/authorize.d.ts.map +0 -1
- package/dist/middleware/authorize.js +0 -30
- package/dist/middleware/authorize.js.map +0 -1
- package/dist/middleware/errorHandler.d.ts +0 -71
- package/dist/middleware/errorHandler.d.ts.map +0 -1
- package/dist/middleware/errorHandler.js +0 -74
- package/dist/middleware/errorHandler.js.map +0 -1
- package/dist/middleware/permit.d.ts +0 -62
- package/dist/middleware/permit.d.ts.map +0 -1
- package/dist/middleware/permit.js +0 -61
- package/dist/middleware/permit.js.map +0 -1
- package/dist/middleware/protect.d.ts +0 -31
- package/dist/middleware/protect.d.ts.map +0 -1
- package/dist/middleware/protect.js +0 -54
- package/dist/middleware/protect.js.map +0 -1
- package/dist/middleware/router.d.ts +0 -34
- package/dist/middleware/router.d.ts.map +0 -1
- package/dist/middleware/router.js +0 -264
- package/dist/middleware/router.js.map +0 -1
- package/dist/services/auth.d.ts +0 -85
- package/dist/services/auth.d.ts.map +0 -1
- package/dist/services/auth.js +0 -173
- package/dist/services/auth.js.map +0 -1
- package/dist/types/auth.d.ts +0 -450
- package/dist/types/auth.d.ts.map +0 -1
- package/dist/types/auth.js +0 -21
- package/dist/types/auth.js.map +0 -1
- package/templates/drizzle/adapter.ts +0 -154
- package/templates/drizzle/auth.ts +0 -82
- package/templates/drizzle/schema.ts +0 -47
- package/templates/prisma/adapter.ts +0 -122
- package/templates/prisma/auth.ts +0 -85
- package/templates/prisma/schema.prisma +0 -56
package/dist/index.d.ts
CHANGED
|
@@ -1,18 +1,552 @@
|
|
|
1
|
-
import
|
|
1
|
+
import { Dialect } from 'kysely';
|
|
2
|
+
import { Request, ErrorRequestHandler, RequestHandler, Router } from 'express';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Discriminant codes for built-in {@link SentriError} instances.
|
|
6
|
+
*
|
|
7
|
+
* - `INVALID_CREDENTIALS` — identifier or password did not match (intentionally vague to prevent user enumeration)
|
|
8
|
+
* - `USER_NOT_FOUND` — an operation required a user that does not exist
|
|
9
|
+
* - `USER_ALREADY_EXISTS` — registration was attempted with an identifier already in the database
|
|
10
|
+
* - `TOKEN_EXPIRED` — the JWT was valid but its `exp` claim is in the past
|
|
11
|
+
* - `TOKEN_INVALID` — the JWT could not be verified (bad signature, malformed, wrong type)
|
|
12
|
+
* - `FORBIDDEN` — the user is authenticated but lacks the required role
|
|
13
|
+
* - `UNAUTHORIZED` — no valid access token was present on the request, or the session was revoked
|
|
14
|
+
* - `INVALID_ROLE` — a role name was used that is not in `validRoles`
|
|
15
|
+
* - `VALIDATION_ERROR` — a required field was missing or had an invalid value
|
|
16
|
+
* - `CONFIGURATION_ERROR` — `createAuth` was called with an invalid configuration
|
|
17
|
+
*
|
|
18
|
+
* When you extend {@link SentriError} for your own error types you can use any
|
|
19
|
+
* string as `code` — it does not need to be one of these built-in values.
|
|
20
|
+
*/
|
|
21
|
+
type SentriErrorCode = 'INVALID_CREDENTIALS' | 'USER_NOT_FOUND' | 'USER_ALREADY_EXISTS' | 'TOKEN_EXPIRED' | 'TOKEN_INVALID' | 'FORBIDDEN' | 'UNAUTHORIZED' | 'INVALID_ROLE' | 'VALIDATION_ERROR' | 'CONFIGURATION_ERROR';
|
|
22
|
+
/**
|
|
23
|
+
* Default HTTP status codes for built-in error codes.
|
|
24
|
+
* Custom codes not in this map default to 500.
|
|
25
|
+
*
|
|
26
|
+
* @internal
|
|
27
|
+
*/
|
|
28
|
+
declare const SENTRI_ERROR_STATUS: Record<string, number>;
|
|
29
|
+
/**
|
|
30
|
+
* Base error class for all authentication and authorization failures in sentri.
|
|
31
|
+
*
|
|
32
|
+
* Every error thrown by sentri is an instance of `SentriError`. The `code`
|
|
33
|
+
* property is a machine-readable string that lets you distinguish error
|
|
34
|
+
* types without string-matching on the message. Built-in codes are listed
|
|
35
|
+
* in {@link SentriErrorCode}; custom subclasses may use any string.
|
|
36
|
+
*
|
|
37
|
+
* The `statusCode` property holds the HTTP status that the built-in router
|
|
38
|
+
* and `auth.errorHandler()` will use in the response. For built-in codes
|
|
39
|
+
* it is derived automatically. Pass it explicitly when subclassing with a
|
|
40
|
+
* custom code.
|
|
41
|
+
*
|
|
42
|
+
* ---
|
|
43
|
+
*
|
|
44
|
+
* **Extending SentriError**
|
|
45
|
+
*
|
|
46
|
+
* You can create application-specific error classes by extending `SentriError`.
|
|
47
|
+
* Any subclass will be caught automatically by `auth.errorHandler()` because
|
|
48
|
+
* `instanceof SentriError` is `true` for all subclasses.
|
|
49
|
+
*
|
|
50
|
+
* ```typescript
|
|
51
|
+
* import { SentriError } from 'sentri';
|
|
52
|
+
*
|
|
53
|
+
* export class PaymentError extends SentriError {
|
|
54
|
+
* constructor(message: string) {
|
|
55
|
+
* super('PAYMENT_FAILED', message, 402);
|
|
56
|
+
* }
|
|
57
|
+
* }
|
|
58
|
+
*
|
|
59
|
+
* router.post('/checkout', auth.protect(), async (req, res) => {
|
|
60
|
+
* const ok = await chargeCard(req.body.cardToken);
|
|
61
|
+
* if (!ok) throw new PaymentError('Card declined');
|
|
62
|
+
* res.json({ success: true });
|
|
63
|
+
* });
|
|
64
|
+
* ```
|
|
65
|
+
*
|
|
66
|
+
* ---
|
|
67
|
+
*
|
|
68
|
+
* **Error handling in custom routes**
|
|
69
|
+
*
|
|
70
|
+
* ```typescript
|
|
71
|
+
* app.use('/auth', auth.router());
|
|
72
|
+
* app.use('/api', apiRouter);
|
|
73
|
+
*
|
|
74
|
+
* // Mount after all routes — catches SentriError from sentri AND your subclasses
|
|
75
|
+
* app.use(auth.errorHandler());
|
|
76
|
+
* ```
|
|
77
|
+
*/
|
|
78
|
+
declare class SentriError extends Error {
|
|
79
|
+
/**
|
|
80
|
+
* Machine-readable error code.
|
|
81
|
+
* Built-in codes are defined by {@link SentriErrorCode}.
|
|
82
|
+
* Custom subclasses may use any string.
|
|
83
|
+
*/
|
|
84
|
+
readonly code: string;
|
|
85
|
+
/**
|
|
86
|
+
* HTTP status code associated with this error.
|
|
87
|
+
* Derived automatically for built-in codes; pass it explicitly when
|
|
88
|
+
* subclassing with a custom `code`.
|
|
89
|
+
*/
|
|
90
|
+
readonly statusCode: number;
|
|
91
|
+
constructor(code: SentriErrorCode | (string & {}), message: string, statusCode?: number);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
interface ApiResponse<T = null> {
|
|
95
|
+
error: boolean;
|
|
96
|
+
statusCode: number;
|
|
97
|
+
message: string;
|
|
98
|
+
data: T | null;
|
|
99
|
+
}
|
|
100
|
+
interface AuthUser<TRole extends string = string> {
|
|
101
|
+
id: string;
|
|
102
|
+
identifier: string;
|
|
103
|
+
roles: TRole[];
|
|
104
|
+
}
|
|
105
|
+
type RegisterResult<TRole extends string = string> = {
|
|
106
|
+
success: true;
|
|
107
|
+
user: AuthUser<TRole>;
|
|
108
|
+
} | {
|
|
109
|
+
success: false;
|
|
110
|
+
error: SentriError;
|
|
111
|
+
};
|
|
112
|
+
type AuthResult<TRole extends string = string> = {
|
|
113
|
+
success: true;
|
|
114
|
+
accessToken: string;
|
|
115
|
+
refreshToken: string;
|
|
116
|
+
user: AuthUser<TRole>;
|
|
117
|
+
} | {
|
|
118
|
+
success: false;
|
|
119
|
+
error: SentriError;
|
|
120
|
+
};
|
|
121
|
+
type AssignRolesResult<TRole extends string = string> = {
|
|
122
|
+
success: true;
|
|
123
|
+
user: AuthUser<TRole>;
|
|
124
|
+
} | {
|
|
125
|
+
success: false;
|
|
126
|
+
error: SentriError;
|
|
127
|
+
};
|
|
128
|
+
type GetUserResult<TRole extends string = string> = {
|
|
129
|
+
success: true;
|
|
130
|
+
user: AuthUser<TRole>;
|
|
131
|
+
} | {
|
|
132
|
+
success: false;
|
|
133
|
+
error: SentriError;
|
|
134
|
+
};
|
|
135
|
+
type ChangeIdentifierResult<TRole extends string = string> = {
|
|
136
|
+
success: true;
|
|
137
|
+
user: AuthUser<TRole>;
|
|
138
|
+
} | {
|
|
139
|
+
success: false;
|
|
140
|
+
error: SentriError;
|
|
141
|
+
};
|
|
142
|
+
type ChangePasswordResult = {
|
|
143
|
+
success: true;
|
|
144
|
+
} | {
|
|
145
|
+
success: false;
|
|
146
|
+
error: SentriError;
|
|
147
|
+
};
|
|
148
|
+
type RefreshResult<TRole extends string = string> = {
|
|
149
|
+
success: true;
|
|
150
|
+
accessToken: string;
|
|
151
|
+
refreshToken: string;
|
|
152
|
+
user: AuthUser<TRole>;
|
|
153
|
+
} | {
|
|
154
|
+
success: false;
|
|
155
|
+
error: SentriError;
|
|
156
|
+
};
|
|
157
|
+
interface RegisterInput<TRole extends string = string> {
|
|
158
|
+
identifier: string;
|
|
159
|
+
password: string;
|
|
160
|
+
roles?: TRole[];
|
|
161
|
+
}
|
|
162
|
+
interface LoginInput {
|
|
163
|
+
identifier: string;
|
|
164
|
+
password: string;
|
|
165
|
+
}
|
|
166
|
+
interface CookieConfig {
|
|
167
|
+
name?: string;
|
|
168
|
+
httpOnly?: boolean;
|
|
169
|
+
secure?: boolean;
|
|
170
|
+
sameSite?: 'strict' | 'lax' | 'none';
|
|
171
|
+
path?: string;
|
|
172
|
+
}
|
|
173
|
+
interface AccessCookieConfig {
|
|
174
|
+
name?: string;
|
|
175
|
+
secure?: boolean;
|
|
176
|
+
sameSite?: 'strict' | 'lax' | 'none';
|
|
177
|
+
path?: string;
|
|
178
|
+
}
|
|
179
|
+
interface AuthHooks {
|
|
180
|
+
onLogin?: (user: AuthUser) => void | Promise<void>;
|
|
181
|
+
onFailedLogin?: (identifier: string, error: SentriError) => void | Promise<void>;
|
|
182
|
+
onLogout?: (userId: string) => void | Promise<void>;
|
|
183
|
+
}
|
|
184
|
+
interface RouterHandlers {
|
|
185
|
+
register?: (input: RegisterInput) => Promise<RegisterResult>;
|
|
186
|
+
login?: (input: LoginInput) => Promise<AuthResult>;
|
|
187
|
+
refresh?: (refreshToken: string) => Promise<RefreshResult>;
|
|
188
|
+
logout?: (refreshToken: string | undefined) => Promise<void>;
|
|
189
|
+
logoutAll?: (userId: string) => Promise<void>;
|
|
190
|
+
assignRoles?: (userId: string, roles: string[]) => Promise<AssignRolesResult>;
|
|
191
|
+
changeIdentifier?: (userId: string, newIdentifier: string) => Promise<ChangeIdentifierResult>;
|
|
192
|
+
changePassword?: (userId: string, currentPassword: string, newPassword: string) => Promise<ChangePasswordResult>;
|
|
193
|
+
}
|
|
194
|
+
interface ServerAuthConfig<TRole extends string = string> {
|
|
195
|
+
mode: 'server';
|
|
196
|
+
/** Kysely Dialect (e.g. PostgresDialect, MysqlDialect, SqliteDialect). */
|
|
197
|
+
dialect: Dialect;
|
|
198
|
+
/**
|
|
199
|
+
* JWT signing secret.
|
|
200
|
+
* - HS256/HS384/HS512: plain string, minimum 32 characters.
|
|
201
|
+
* - RS256/RS384/RS512: RSA private key in PEM format.
|
|
202
|
+
*/
|
|
203
|
+
secret: string;
|
|
204
|
+
/**
|
|
205
|
+
* JWT signing algorithm.
|
|
206
|
+
* Use RS256/RS384/RS512 to enable the GET /keys endpoint for SSO.
|
|
207
|
+
* @default 'HS256'
|
|
208
|
+
*/
|
|
209
|
+
algorithm?: 'HS256' | 'HS384' | 'HS512' | 'RS256' | 'RS384' | 'RS512';
|
|
210
|
+
validRoles: readonly TRole[];
|
|
211
|
+
/** @default '15m' */
|
|
212
|
+
accessExpiresIn?: string | number;
|
|
213
|
+
/** @default '7d' */
|
|
214
|
+
refreshExpiresIn?: string | number;
|
|
215
|
+
/** @default 12 */
|
|
216
|
+
saltRounds?: number;
|
|
217
|
+
apiKey?: string;
|
|
218
|
+
cookie?: CookieConfig;
|
|
219
|
+
accessCookie?: AccessCookieConfig;
|
|
220
|
+
hooks?: AuthHooks;
|
|
221
|
+
router?: RouterHandlers;
|
|
222
|
+
isTokenRevoked?: (sessionId: string) => boolean | Promise<boolean>;
|
|
223
|
+
/**
|
|
224
|
+
* Redis connection URL (e.g. `redis://localhost:6379`).
|
|
225
|
+
* When set, `auth.idempotencyMiddleware()` uses Redis as the cache backend
|
|
226
|
+
* instead of an in-memory Map — required for multi-process deployments.
|
|
227
|
+
*/
|
|
228
|
+
redisUrl?: string;
|
|
229
|
+
}
|
|
230
|
+
interface ClientAuthConfig<TRole extends string = string> {
|
|
231
|
+
mode: 'client';
|
|
232
|
+
/** URL of the auth server's public key endpoint (e.g. https://auth.myapp.com/auth/keys). */
|
|
233
|
+
keyUri: string;
|
|
234
|
+
/** Optional — only needed for TypeScript type safety on authorize(). */
|
|
235
|
+
validRoles?: readonly TRole[];
|
|
236
|
+
}
|
|
237
|
+
type AuthConfig<TRole extends string = string> = ServerAuthConfig<TRole> | ClientAuthConfig<TRole>;
|
|
238
|
+
|
|
239
|
+
/** A function that determines whether the current request is permitted. */
|
|
240
|
+
type PermitCheck = (request: Request) => boolean | Promise<boolean>;
|
|
241
|
+
/**
|
|
242
|
+
* Options for {@link permit} when you need role-bypass alongside a resource check.
|
|
243
|
+
*
|
|
244
|
+
* @example
|
|
245
|
+
* // Admins can edit any post; others only their own
|
|
246
|
+
* auth.permit({
|
|
247
|
+
* roles: ['admin'],
|
|
248
|
+
* check: async (request) => {
|
|
249
|
+
* const post = await db.findPost(request.params['id']);
|
|
250
|
+
* return post?.authorId === request.user!.id;
|
|
251
|
+
* },
|
|
252
|
+
* })
|
|
253
|
+
*/
|
|
254
|
+
interface PermitOptions<TRole extends string> {
|
|
255
|
+
/**
|
|
256
|
+
* Roles whose members are granted access without running `check`.
|
|
257
|
+
* Use for privileged roles like `'admin'` that should bypass ownership checks.
|
|
258
|
+
*/
|
|
259
|
+
roles?: TRole[];
|
|
260
|
+
/**
|
|
261
|
+
* Permission check executed when the user has none of the bypass `roles`.
|
|
262
|
+
* Return `true` to allow, `false` to deny with `FORBIDDEN`.
|
|
263
|
+
* May be async — useful for database-backed ownership checks.
|
|
264
|
+
*/
|
|
265
|
+
check: PermitCheck;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* Options for {@link createErrorHandler}.
|
|
270
|
+
*/
|
|
271
|
+
interface ErrorHandlerOptions {
|
|
272
|
+
/**
|
|
273
|
+
* Called for errors that are **not** a `SentriError` instance (or subclass).
|
|
274
|
+
*
|
|
275
|
+
* Use this to log unexpected server errors before the generic 500 response
|
|
276
|
+
* is sent. The error is passed as-is and may be any unknown value.
|
|
277
|
+
*
|
|
278
|
+
* @example
|
|
279
|
+
* app.use(auth.errorHandler({
|
|
280
|
+
* onUnhandled: (err) => logger.error('Unhandled error', { err }),
|
|
281
|
+
* }));
|
|
282
|
+
*/
|
|
283
|
+
onUnhandled?: (error: unknown) => void;
|
|
284
|
+
}
|
|
285
|
+
/**
|
|
286
|
+
* Creates an Express error-handling middleware that formats every `SentriError`
|
|
287
|
+
* (including subclasses) into the standard sentri response envelope:
|
|
288
|
+
*
|
|
289
|
+
* ```json
|
|
290
|
+
* { "error": true, "statusCode": 401, "code": "UNAUTHORIZED", "message": "...", "data": null }
|
|
291
|
+
* ```
|
|
292
|
+
*
|
|
293
|
+
* Prefer using `auth.errorHandler()` instead of calling this directly:
|
|
294
|
+
*
|
|
295
|
+
* ```typescript
|
|
296
|
+
* app.use('/auth', auth.router());
|
|
297
|
+
* app.use('/api', apiRouter);
|
|
298
|
+
*
|
|
299
|
+
* // Must come after all route/middleware registrations
|
|
300
|
+
* app.use(auth.errorHandler());
|
|
301
|
+
* ```
|
|
302
|
+
*
|
|
303
|
+
* ---
|
|
304
|
+
*
|
|
305
|
+
* **Works with built-in sentri errors and your own subclasses**
|
|
306
|
+
*
|
|
307
|
+
* Because `instanceof SentriError` matches any subclass, you can define
|
|
308
|
+
* application-specific error types and have them automatically formatted
|
|
309
|
+
* by this handler:
|
|
310
|
+
*
|
|
311
|
+
* ```typescript
|
|
312
|
+
* import { SentriError } from 'sentri';
|
|
313
|
+
*
|
|
314
|
+
* // Extend SentriError for domain-specific failures
|
|
315
|
+
* export class NotFoundError extends SentriError {
|
|
316
|
+
* constructor(resource: string) {
|
|
317
|
+
* super('NOT_FOUND', `${resource} not found`, 404);
|
|
318
|
+
* }
|
|
319
|
+
* }
|
|
320
|
+
*
|
|
321
|
+
* export class PaymentError extends SentriError {
|
|
322
|
+
* constructor(message: string) {
|
|
323
|
+
* super('PAYMENT_FAILED', message, 402);
|
|
324
|
+
* }
|
|
325
|
+
* }
|
|
326
|
+
*
|
|
327
|
+
* // All of the above are caught and formatted by one handler
|
|
328
|
+
* app.use(auth.errorHandler({
|
|
329
|
+
* onUnhandled: (err) => console.error('Unexpected error:', err),
|
|
330
|
+
* }));
|
|
331
|
+
* ```
|
|
332
|
+
*
|
|
333
|
+
* @param options - Optional configuration (see {@link ErrorHandlerOptions}).
|
|
334
|
+
* @returns An Express `ErrorRequestHandler` (4-argument middleware).
|
|
335
|
+
*/
|
|
336
|
+
declare function createErrorHandler(options?: ErrorHandlerOptions): ErrorRequestHandler;
|
|
337
|
+
|
|
338
|
+
interface IdempotencyOptions {
|
|
339
|
+
/** @default 300_000 (5 minutes) */
|
|
340
|
+
ttl?: number;
|
|
341
|
+
/** @default 'X-Idempotency-Key' */
|
|
342
|
+
header?: string;
|
|
343
|
+
/** @default ['POST', 'PUT', 'PATCH'] */
|
|
344
|
+
methods?: string[];
|
|
345
|
+
/**
|
|
346
|
+
* Max in-memory entries (ignored when redisUrl is set).
|
|
347
|
+
* @default 10_000
|
|
348
|
+
*/
|
|
349
|
+
maxSize?: number;
|
|
350
|
+
/**
|
|
351
|
+
* Redis connection URL (e.g. `redis://localhost:6379`).
|
|
352
|
+
* When set, uses Redis as the cache backend instead of in-memory Map.
|
|
353
|
+
*/
|
|
354
|
+
redisUrl?: string;
|
|
355
|
+
}
|
|
356
|
+
/**
|
|
357
|
+
* Middleware that deduplicates non-idempotent requests.
|
|
358
|
+
*
|
|
359
|
+
* When a request arrives with a matching idempotency key header, the cached
|
|
360
|
+
* response is replayed immediately — the handler is not called again.
|
|
361
|
+
* Responses are only cached for 2xx status codes.
|
|
362
|
+
*
|
|
363
|
+
* Two backends are available:
|
|
364
|
+
* - **In-memory** (default) — zero dependencies, single-process only.
|
|
365
|
+
* - **Redis** — set `redisUrl` to share state across processes/instances.
|
|
366
|
+
*
|
|
367
|
+
* When using `createAuthServer()`, prefer `auth.idempotencyMiddleware()` instead —
|
|
368
|
+
* it automatically inherits the `redisUrl` from server config.
|
|
369
|
+
*
|
|
370
|
+
* @example
|
|
371
|
+
* // Standalone usage
|
|
372
|
+
* app.use(createIdempotencyMiddleware({ ttl: 60_000 }));
|
|
373
|
+
*
|
|
374
|
+
* @example
|
|
375
|
+
* // Multi-process (Redis)
|
|
376
|
+
* app.use(createIdempotencyMiddleware({ redisUrl: 'redis://localhost:6379' }));
|
|
377
|
+
*/
|
|
378
|
+
declare function createIdempotencyMiddleware(options?: IdempotencyOptions): RequestHandler;
|
|
379
|
+
|
|
380
|
+
interface AuthClient<TRole extends string = string> {
|
|
381
|
+
/** JWT authentication middleware. Reads Bearer token or access_token cookie. */
|
|
382
|
+
protect(): RequestHandler;
|
|
383
|
+
/** Role-based access middleware. Must follow protect(). */
|
|
384
|
+
authorize(...roles: TRole[]): RequestHandler;
|
|
385
|
+
/** Resource-level permission middleware. Must follow protect(). */
|
|
386
|
+
permit(check: PermitCheck): RequestHandler;
|
|
387
|
+
permit(options: PermitOptions<TRole>): RequestHandler;
|
|
388
|
+
/** Global error handler middleware. Mount after all routes. */
|
|
389
|
+
errorHandler(options?: ErrorHandlerOptions): ErrorRequestHandler;
|
|
390
|
+
}
|
|
391
|
+
interface ServerAuthClient<TRole extends string = string> extends AuthClient<TRole> {
|
|
392
|
+
/** Hash a plain-text password. */
|
|
393
|
+
hashPassword(plain: string): Promise<string>;
|
|
394
|
+
/** Compare a plain-text password against a bcrypt hash. */
|
|
395
|
+
verifyPassword(plain: string, hash: string): Promise<boolean>;
|
|
396
|
+
/** Sign an access token. */
|
|
397
|
+
signAccessToken(payload: AuthUser<TRole>): string;
|
|
398
|
+
/** Sign a refresh token. */
|
|
399
|
+
signRefreshToken(sessionId: string): string;
|
|
400
|
+
/** Verify an access token. */
|
|
401
|
+
verifyAccessToken(token: string): AuthUser<TRole>;
|
|
402
|
+
/** Verify a refresh token. */
|
|
403
|
+
verifyRefreshToken(token: string): {
|
|
404
|
+
sessionId: string;
|
|
405
|
+
};
|
|
406
|
+
/** Extract the raw access token from an Express request. */
|
|
407
|
+
getCurrentAccessToken(request: Request): string | undefined;
|
|
408
|
+
/**
|
|
409
|
+
* Pre-built Express Router with auth endpoints:
|
|
410
|
+
* POST /register, POST /login, POST /refresh, POST /logout,
|
|
411
|
+
* POST /logout-all, GET /me, POST /users/:userId/roles,
|
|
412
|
+
* GET /keys (only when algorithm is RS256/RS384/RS512)
|
|
413
|
+
*/
|
|
414
|
+
router(): Router;
|
|
415
|
+
/**
|
|
416
|
+
* Run database migrations to create sentri_users and sentri_sessions tables.
|
|
417
|
+
* Safe to call on every startup — uses IF NOT EXISTS.
|
|
418
|
+
*/
|
|
419
|
+
migrate(): Promise<void>;
|
|
420
|
+
/**
|
|
421
|
+
* Idempotency middleware. Caches successful responses and replays them for
|
|
422
|
+
* duplicate requests with the same idempotency key header.
|
|
423
|
+
* Uses Redis backend when `redisUrl` is set in server config; otherwise in-memory.
|
|
424
|
+
*/
|
|
425
|
+
idempotencyMiddleware(options?: IdempotencyOptions): RequestHandler;
|
|
426
|
+
/** Register a new user. */
|
|
427
|
+
register(input: RegisterInput<TRole>): Promise<RegisterResult<TRole>>;
|
|
428
|
+
/** Authenticate a user, returns access + refresh tokens. */
|
|
429
|
+
login(input: LoginInput): Promise<AuthResult<TRole>>;
|
|
430
|
+
/** Rotate a refresh token, returns new token pair. */
|
|
431
|
+
refresh(refreshToken: string): Promise<RefreshResult<TRole>>;
|
|
432
|
+
/** Invalidate the session associated with the given refresh token. */
|
|
433
|
+
logout(refreshToken: string): Promise<void>;
|
|
434
|
+
/** Invalidate all sessions for a user. */
|
|
435
|
+
logoutAll(userId: string): Promise<void>;
|
|
436
|
+
/** Fetch a user by ID. Returns `success: false` if not found. */
|
|
437
|
+
getUser(userId: string): Promise<GetUserResult<TRole>>;
|
|
438
|
+
/** Change a user's identifier (username/email). */
|
|
439
|
+
changeIdentifier(userId: string, newIdentifier: string): Promise<ChangeIdentifierResult<TRole>>;
|
|
440
|
+
/** Change a user's password and revoke all their sessions. */
|
|
441
|
+
changePassword(userId: string, currentPassword: string, newPassword: string): Promise<ChangePasswordResult>;
|
|
442
|
+
/** Add roles to a user (existing roles are preserved). */
|
|
443
|
+
assignRoles(userId: string, roles: TRole[]): Promise<AssignRolesResult<TRole>>;
|
|
444
|
+
}
|
|
445
|
+
interface ClientAuthClient<TRole extends string = string> extends AuthClient<TRole> {
|
|
446
|
+
}
|
|
447
|
+
/**
|
|
448
|
+
* Create a Sentri auth client.
|
|
449
|
+
*
|
|
450
|
+
* Pass `mode: 'server'` to get a full auth server with database, JWT signing,
|
|
451
|
+
* and built-in endpoints. Pass `mode: 'client'` to get a stateless verifier
|
|
452
|
+
* that validates tokens via the server's JWKS endpoint.
|
|
453
|
+
*
|
|
454
|
+
* For server mode with PostgreSQL, prefer `createAuthServer()` — it handles
|
|
455
|
+
* RSA key generation and database setup automatically.
|
|
456
|
+
*
|
|
457
|
+
* @example
|
|
458
|
+
* // Server mode
|
|
459
|
+
* const auth = createAuth({
|
|
460
|
+
* mode: 'server',
|
|
461
|
+
* dialect: new PostgresDialect({ pool: new Pool({ connectionString: DATABASE_URL }) }),
|
|
462
|
+
* secret: process.env.JWT_SECRET!,
|
|
463
|
+
* validRoles: ['user', 'admin'] as const,
|
|
464
|
+
* });
|
|
465
|
+
*
|
|
466
|
+
* @example
|
|
467
|
+
* // Client mode
|
|
468
|
+
* const auth = createAuth({
|
|
469
|
+
* mode: 'client',
|
|
470
|
+
* keyUri: 'https://auth.myapp.com/auth/keys',
|
|
471
|
+
* });
|
|
472
|
+
*/
|
|
473
|
+
declare function createAuth<TRole extends string = string>(config: ServerAuthConfig<TRole>): ServerAuthClient<TRole>;
|
|
474
|
+
declare function createAuth<TRole extends string = string>(config: ClientAuthConfig<TRole>): ClientAuthClient<TRole>;
|
|
475
|
+
|
|
476
|
+
type PostgresConfig = {
|
|
477
|
+
connectionString: string;
|
|
478
|
+
max?: number;
|
|
479
|
+
} | {
|
|
480
|
+
host?: string;
|
|
481
|
+
port?: number;
|
|
482
|
+
database: string;
|
|
483
|
+
user: string;
|
|
484
|
+
password: string;
|
|
485
|
+
max?: number;
|
|
486
|
+
};
|
|
487
|
+
|
|
488
|
+
interface CreateServerOptions<TRole extends string = string> {
|
|
489
|
+
validRoles: readonly TRole[];
|
|
490
|
+
db: PostgresConfig;
|
|
491
|
+
accessExpiresIn?: string | number;
|
|
492
|
+
refreshExpiresIn?: string | number;
|
|
493
|
+
saltRounds?: number;
|
|
494
|
+
apiKey?: string;
|
|
495
|
+
cookie?: CookieConfig;
|
|
496
|
+
accessCookie?: AccessCookieConfig;
|
|
497
|
+
hooks?: AuthHooks;
|
|
498
|
+
router?: RouterHandlers;
|
|
499
|
+
isTokenRevoked?: (sessionId: string) => boolean | Promise<boolean>;
|
|
500
|
+
/**
|
|
501
|
+
* Redis connection URL (e.g. `redis://localhost:6379`).
|
|
502
|
+
* When set, `auth.idempotencyMiddleware()` uses Redis as the cache backend.
|
|
503
|
+
*/
|
|
504
|
+
redisUrl?: string;
|
|
505
|
+
}
|
|
506
|
+
/**
|
|
507
|
+
* Create a Sentri auth server for PostgreSQL.
|
|
508
|
+
*
|
|
509
|
+
* Convenience wrapper over `createAuth()` that:
|
|
510
|
+
* - Accepts plain PostgreSQL connection params instead of a Kysely dialect
|
|
511
|
+
* - Generates an RSA-2048 key pair at startup (RS256, ephemeral per process)
|
|
512
|
+
* - Exposes `GET /keys` (JWKS) automatically for SSO with client-mode apps
|
|
513
|
+
*
|
|
514
|
+
* @example
|
|
515
|
+
* const auth = createAuthServer({
|
|
516
|
+
* validRoles: ['user', 'admin'] as const,
|
|
517
|
+
* db: { connectionString: process.env.DATABASE_URL! },
|
|
518
|
+
* });
|
|
519
|
+
* await auth.migrate();
|
|
520
|
+
* app.use('/auth', auth.router());
|
|
521
|
+
*
|
|
522
|
+
* @example
|
|
523
|
+
* // With Redis idempotency cache (multi-process deployments)
|
|
524
|
+
* const auth = createAuthServer({
|
|
525
|
+
* validRoles: ['user', 'admin'] as const,
|
|
526
|
+
* db: { connectionString: process.env.DATABASE_URL! },
|
|
527
|
+
* redisUrl: process.env.REDIS_URL,
|
|
528
|
+
* });
|
|
529
|
+
* app.use(auth.idempotencyMiddleware());
|
|
530
|
+
*/
|
|
531
|
+
declare function createAuthServer<TRole extends string = string>(options: CreateServerOptions<TRole>): ServerAuthClient<TRole>;
|
|
532
|
+
|
|
533
|
+
/**
|
|
534
|
+
* Extract the raw access token string from an Express request.
|
|
535
|
+
* Reads `Authorization: Bearer <token>` header first; falls back to the
|
|
536
|
+
* `access_token` cookie (or the name set in `accessCookie.name`).
|
|
537
|
+
* Returns `undefined` when no token is present.
|
|
538
|
+
*/
|
|
539
|
+
declare function getCurrentAccessToken(request: Request, config: ServerAuthConfig): string | undefined;
|
|
540
|
+
|
|
541
|
+
declare function register(input: RegisterInput, config: ServerAuthConfig): Promise<RegisterResult>;
|
|
542
|
+
|
|
2
543
|
declare global {
|
|
3
544
|
namespace Express {
|
|
4
545
|
interface Request {
|
|
5
546
|
user?: AuthUser;
|
|
547
|
+
requestId?: string;
|
|
6
548
|
}
|
|
7
549
|
}
|
|
8
550
|
}
|
|
9
|
-
|
|
10
|
-
export type
|
|
11
|
-
export type { AuthClient } from './client.js';
|
|
12
|
-
export type { ErrorHandlerOptions } from './middleware/errorHandler.js';
|
|
13
|
-
export { SentriError, AUTH_ERROR_STATUS } from './errors/AuthError.js';
|
|
14
|
-
export { createAuth } from './client.js';
|
|
15
|
-
export { createErrorHandler } from './middleware/errorHandler.js';
|
|
16
|
-
export { register } from './services/auth.js';
|
|
17
|
-
export type { PermitCheck, PermitOptions } from './middleware/permit.js';
|
|
18
|
-
//# sourceMappingURL=index.d.ts.map
|
|
551
|
+
|
|
552
|
+
export { type AccessCookieConfig, type ApiResponse, type AssignRolesResult, type AuthClient, type AuthConfig, type AuthHooks, type AuthResult, type AuthUser, type ChangeIdentifierResult, type ChangePasswordResult, type ClientAuthClient, type ClientAuthConfig, type CookieConfig, type CreateServerOptions, type ErrorHandlerOptions, type GetUserResult, type IdempotencyOptions, type LoginInput, type PermitCheck, type PermitOptions, type PostgresConfig, type RefreshResult, type RegisterInput, type RegisterResult, type RouterHandlers, SENTRI_ERROR_STATUS, SentriError, type SentriErrorCode, type ServerAuthClient, type ServerAuthConfig, createAuth, createAuthServer, createErrorHandler, createIdempotencyMiddleware, getCurrentAccessToken, register };
|
package/dist/index.js
CHANGED
|
@@ -1,5 +1 @@
|
|
|
1
|
-
export { SentriError, AUTH_ERROR_STATUS } from './errors/AuthError.js';
|
|
2
|
-
export { createAuth } from './client.js';
|
|
3
|
-
export { createErrorHandler } from './middleware/errorHandler.js';
|
|
4
|
-
export { register } from './services/auth.js';
|
|
5
|
-
//# sourceMappingURL=index.js.map
|
|
1
|
+
import Re from'bcrypt';import H from'jsonwebtoken';import {generateKeyPairSync,randomUUID,createPublicKey,createPrivateKey}from'crypto';import {Kysely,sql,PostgresDialect}from'kysely';import {Router}from'express';import {Redis}from'ioredis';import {Pool}from'pg';var pe=Object.assign(Object.create(null),{UNAUTHORIZED:401,TOKEN_EXPIRED:401,TOKEN_INVALID:401,INVALID_CREDENTIALS:401,FORBIDDEN:403,USER_NOT_FOUND:404,USER_ALREADY_EXISTS:409,INVALID_ROLE:400,VALIDATION_ERROR:400,CONFIGURATION_ERROR:500}),i=class extends Error{code;statusCode;constructor(r,s,t){super(s),this.name="SentriError",this.code=r,this.statusCode=t??pe[r]??500;}};async function D(e,r=12){return Re.hash(e,r)}async function K(e,r){return Re.compare(e,r)}var ye=new Map,we=new Map,nr=3600*1e3;function Ce(e){let r=ye.get(e);if(!r){let s=createPrivateKey(e),t=createPublicKey(s),n=t.export({format:"jwk"}),o=Buffer.from(e).slice(0,8).toString("base64url"),c={...n,use:"sig",kid:o};r={kid:o,publicKey:t,jwk:c},ye.set(e,r);}return r}function ke(e){let{jwk:r}=Ce(e);return {keys:[r]}}function Ie(e){return Ce(e).publicKey}async function Se(e){let r=Date.now(),s=we.get(e);if(s&&r-s.fetchedAt<nr)return s.publicKey;let t=await fetch(e);if(!t.ok)throw new i("CONFIGURATION_ERROR",`Failed to fetch public key from ${e}: HTTP ${t.status}`);let n=await t.json();if(!n.keys||n.keys.length===0)throw new i("CONFIGURATION_ERROR",`No keys found in JWKS response from ${e}`);let o=n.keys[0],c=createPublicKey({key:o,format:"jwk"});return we.set(e,{publicKey:c,fetchedAt:r}),c}var Te=new WeakMap,ve=32,Ee=10,xe=31;function _e(e){if(e.mode==="client"){if(!e.keyUri||e.keyUri.trim().length===0)throw new i("CONFIGURATION_ERROR","keyUri must not be empty");return}if(!e.secret||e.secret.trim().length===0)throw new i("CONFIGURATION_ERROR","secret must not be empty");if((e.algorithm??"HS256").startsWith("HS")&&e.secret.length<ve)throw new i("CONFIGURATION_ERROR",`secret must be at least ${ve} characters for HMAC algorithms`);let t=e.saltRounds??12;if(!Number.isInteger(t)||t<Ee||t>xe)throw new i("CONFIGURATION_ERROR",`saltRounds must be an integer between ${Ee} and ${xe}`);if(!e.validRoles||e.validRoles.length===0)throw new i("CONFIGURATION_ERROR","validRoles must contain at least one role");if(!e.dialect)throw new i("CONFIGURATION_ERROR","dialect is required in server mode")}function p(e){let r=Te.get(e);if(r)return r;let s={algorithm:e.algorithm??"HS256",accessExpiresIn:e.accessExpiresIn??"15m",refreshExpiresIn:e.refreshExpiresIn??"7d",saltRounds:e.saltRounds??12,validRoles:e.validRoles,validRolesSet:new Set(e.validRoles),cookieName:e.cookie?.name??"refresh_token",accessCookieName:e.accessCookie?.name??"access_token"};return Te.set(e,s),s}var or=/^(\d+)([smhdw])$/,ir={s:1e3,m:6e4,h:36e5,d:864e5,w:6048e5},be=new Map;function x(e){if(typeof e=="number")return e*1e3;let r=be.get(e);if(r!==void 0)return r;let s=or.exec(e);if(!s?.[1]||!s?.[2])throw new Error(`Invalid expiresIn: "${e}". Use e.g. "15m", "7d", "1h".`);let t=ir[s[2]];if(t===void 0)throw new Error(`Invalid expiresIn: "${e}". Use e.g. "15m", "7d", "1h".`);let n=parseInt(s[1],10)*t;return be.set(e,n),n}var Pe=new Map,Oe=new Map,Ne=new Map;function Ue(e){return e.startsWith("RS")||e.startsWith("PS")}function De(e){let r=Pe.get(e);return r||(r={access:`${e}:access`,refresh:`${e}:refresh`},Pe.set(e,r)),r}function cr(e){let r=Oe.get(e);return r||(r=createPrivateKey(e),Oe.set(e,r)),r}function Ke(e){let r=p(e);if(Ue(r.algorithm)){let n=cr(e.secret);return {accessKey:n,refreshKey:n}}let{access:s,refresh:t}=De(e.secret);return {accessKey:s,refreshKey:t}}function He(e,r){let s=p(e);if(Ue(s.algorithm))return Ie(e.secret);let{access:t,refresh:n}=De(e.secret);return r==="access"?t:n}function je(e,r,s,t){let n=`${s}:${t}`,o=Ne.get(n);return o||(o={expiresIn:s,algorithm:t},Ne.set(n,o)),H.sign(e,r,o)}function Le(e,r,s){try{let t=H.verify(e,r,{algorithms:[s]});if(typeof t=="string"||t===null)throw new i("TOKEN_INVALID","Token payload is not an object");return t}catch(t){throw t instanceof i?t:t instanceof H.TokenExpiredError?new i("TOKEN_EXPIRED","Token has expired"):new i("TOKEN_INVALID","Token is invalid or malformed")}}function j(e,r){let s=p(r),{accessKey:t}=Ke(r);return je(e,t,s.accessExpiresIn,s.algorithm)}function L(e,r){let s=p(r),{refreshKey:t}=Ke(r);return je({sessionId:e},t,s.refreshExpiresIn,s.algorithm)}function z(e,r){let s=p(r),t=He(r,"access");return Le(e,t,s.algorithm)}function F(e,r){let s=p(r),t=He(r,"refresh");return Le(e,t,s.algorithm)}function Fe(e,r){try{let s=H.verify(e,r,{algorithms:["RS256","RS384","RS512","PS256","PS384","PS512"]});if(typeof s=="string"||s===null)throw new i("TOKEN_INVALID","Token payload is not an object");return s}catch(s){throw s instanceof i?s:s instanceof H.TokenExpiredError?new i("TOKEN_EXPIRED","Token has expired"):new i("TOKEN_INVALID","Token is invalid or malformed")}}function E(e){return p(e).cookieName}function b(e,r){if(!e)return;let s=`${r}=`,t=0;for(;t<e.length;){for(;t<e.length&&e[t]===" ";)t++;let n=e.indexOf(";",t),o=n===-1?e.length:n;if(e.startsWith(s,t))return e.slice(t+s.length,o);t=o+1;}}function q(e,r,s){let t=s.cookie??{},n=p(s),o=x(n.refreshExpiresIn);e.cookie(E(s),r,{httpOnly:t.httpOnly??true,secure:t.secure??false,sameSite:t.sameSite??"strict",path:t.path??"/",maxAge:o});}function W(e,r){let s=r.cookie??{};e.clearCookie(E(r),{path:s.path??"/"});}function ce(e){return p(e).accessCookieName}function M(e,r,s){if(!s.accessCookie)return;let t=s.accessCookie,n=p(s),o=x(n.accessExpiresIn);e.cookie(ce(s),r,{httpOnly:false,secure:t.secure??false,sameSite:t.sameSite??"strict",path:t.path??"/",maxAge:o});}function ue(e,r){if(!r.accessCookie)return;let s=r.accessCookie;e.clearCookie(ce(r),{path:s.path??"/"});}function $(e,r){let s=e.headers.authorization;return s?.startsWith("Bearer ")?s.slice(7):b(e.headers.cookie,ce(r))}var qe=new Map;function k(e){let r=qe.get(e);return r||(r=new Kysely({dialect:e}),qe.set(e,r)),r}function de(e){try{return JSON.parse(e)}catch{return []}}function $e(e){return JSON.stringify(e)}async function Z(e,r){let s=await e.selectFrom("sentri_users").selectAll().where("identifier","=",r).executeTakeFirst();return s?{id:s.id,identifier:s.identifier,passwordHash:s.password_hash,roles:de(s.roles)}:null}async function J(e,r){let s=await e.selectFrom("sentri_users").selectAll().where("id","=",r).executeTakeFirst();return s?{id:s.id,identifier:s.identifier,passwordHash:s.password_hash,roles:de(s.roles)}:null}async function Je(e,r){let s=randomUUID();return await e.insertInto("sentri_users").values({id:s,identifier:r.identifier,password_hash:r.passwordHash,roles:$e(r.roles)}).execute(),{id:s}}async function Ge(e,r,s){await e.updateTable("sentri_users").set({identifier:s}).where("id","=",r).execute();}async function Xe(e,r,s){await e.updateTable("sentri_users").set({password_hash:s}).where("id","=",r).execute();}async function Ve(e,r,s){await e.updateTable("sentri_users").set({roles:$e(s)}).where("id","=",r).execute();}async function le(e,r){let s=randomUUID();return await e.insertInto("sentri_sessions").values({id:s,user_id:r.userId,expires_at:r.expiresAt}).execute(),{id:s}}async function Be(e,r){let s=await e.selectFrom("sentri_sessions as s").innerJoin("sentri_users as u","u.id","s.user_id").select(["s.id as session_id","s.user_id","s.expires_at","s.created_at as session_created_at","u.id as user_id_col","u.identifier","u.password_hash","u.roles"]).where("s.id","=",r).executeTakeFirst();return s?{id:s.session_id,userId:s.user_id,expiresAt:new Date(s.expires_at),createdAt:new Date(s.session_created_at),user:{id:s.user_id_col,identifier:s.identifier,passwordHash:s.password_hash,roles:de(s.roles)}}:null}async function Y(e,r){await e.deleteFrom("sentri_sessions").where("id","=",r).execute();}async function fe(e,r){await e.deleteFrom("sentri_sessions").where("user_id","=",r).execute();}async function G(e,r){let s=p(r),t=k(r.dialect),n=e.roles??[],o=n.filter(w=>!s.validRolesSet.has(w));if(o.length>0)return {success:false,error:new i("INVALID_ROLE",`Invalid roles: ${o.join(", ")}`)};let c=e.identifier.trim();if(await Z(t,c))return {success:false,error:new i("USER_ALREADY_EXISTS","User already exists")};let g=await D(e.password,s.saltRounds);return {success:true,user:{id:(await Je(t,{identifier:c,passwordHash:g,roles:n})).id,identifier:c,roles:n}}}async function Q(e,r){let s=p(r),t=k(r.dialect),n=await Z(t,e.identifier.trim());if(!n)return {success:false,error:new i("INVALID_CREDENTIALS","Invalid credentials")};if(!await K(e.password,n.passwordHash))return {success:false,error:new i("INVALID_CREDENTIALS","Invalid credentials")};let c=new Date(Date.now()+x(s.refreshExpiresIn)),d=await le(t,{userId:n.id,expiresAt:c}),g={id:n.id,identifier:n.identifier,roles:n.roles},m=j({id:n.id,identifier:n.identifier,roles:n.roles,sessionId:d.id},r),w=L(d.id,r);return {success:true,accessToken:m,refreshToken:w,user:g}}async function _(e,r){let s=p(r),t=k(r.dialect),n;try{({sessionId:n}=F(e,r));}catch(v){return v instanceof i?{success:false,error:v}:{success:false,error:new i("TOKEN_INVALID","Invalid refresh token")}}let o=await Be(t,n);if(!o)return {success:false,error:new i("UNAUTHORIZED","Session not found or revoked")};if(o.expiresAt.getTime()<Date.now())return await Y(t,n),{success:false,error:new i("TOKEN_EXPIRED","Session has expired")};await Y(t,n);let c=new Date(Date.now()+x(s.refreshExpiresIn)),d=await le(t,{userId:o.userId,expiresAt:c}),g={id:o.user.id,identifier:o.user.identifier,roles:o.user.roles},m=j({id:g.id,identifier:g.identifier,roles:g.roles,sessionId:d.id},r),w=L(d.id,r);return {success:true,accessToken:m,refreshToken:w,user:g}}async function ee(e,r){let s=k(r.dialect),t;try{({sessionId:t}=F(e,r));}catch{return}await Y(s,t);}async function re(e,r){let s=k(r.dialect);await fe(s,e);}async function ze(e,r){let s=k(r.dialect),t=await J(s,e);return t?{success:true,user:{id:t.id,identifier:t.identifier,roles:t.roles}}:{success:false,error:new i("USER_NOT_FOUND","User not found")}}async function te(e,r,s){let t=k(s.dialect),n=await J(t,e);if(!n)return {success:false,error:new i("USER_NOT_FOUND","User not found")};let o=r.trim();return await Z(t,o)?{success:false,error:new i("USER_ALREADY_EXISTS","Identifier already taken")}:(await Ge(t,e,o),{success:true,user:{id:n.id,identifier:o,roles:n.roles}})}async function se(e,r,s,t){let n=p(t),o=k(t.dialect),c=await J(o,e);if(!c)return {success:false,error:new i("USER_NOT_FOUND","User not found")};if(!await K(r,c.passwordHash))return {success:false,error:new i("INVALID_CREDENTIALS","Invalid credentials")};let g=await D(s,n.saltRounds);return await Xe(o,e,g),await fe(o,e),{success:true}}async function ne(e,r,s){let t=p(s),n=k(s.dialect),o=r.filter(m=>!t.validRolesSet.has(m));if(o.length>0)return {success:false,error:new i("INVALID_ROLE",`Invalid roles: ${o.join(", ")}`)};let c=await J(n,e);if(!c)return {success:false,error:new i("USER_NOT_FOUND","User not found")};let d=new Set(c.roles);for(let m of r)d.add(m);let g=Array.from(d);return await Ve(n,e,g),{success:true,user:{id:c.id,identifier:c.identifier,roles:g}}}function S(e){return e.mode==="client"?dr(e.keyUri):lr(e)}function dr(e){return async(r,s,t)=>{let n=r.headers.authorization,o=n?.startsWith("Bearer ")?n.slice(7):void 0;if(!o)return t(new i("UNAUTHORIZED","Missing or malformed Authorization header"));try{let c=await Se(e),d=Fe(o,c);r.user={id:d.id,identifier:d.identifier,roles:d.roles},t();}catch(c){t(c);}}}function lr(e){return async(r,s,t)=>{let n=$(r,e);if(!n)return t(new i("UNAUTHORIZED","Missing or malformed Authorization header"));try{let o=z(n,e);if(e.isTokenRevoked&&await e.isTokenRevoked(o.sessionId))return t(new i("UNAUTHORIZED","Token has been revoked"));r.user={id:o.id,identifier:o.identifier,roles:o.roles},t();}catch(o){if(o instanceof i&&o.code==="TOKEN_EXPIRED"){let c=b(r.headers.cookie,E(e));if(!c)return t(new i("UNAUTHORIZED","Token expired. Please login again."));try{let d=await _(c,e);if(!d.success)return t(new i("UNAUTHORIZED","Session expired. Please login again."));q(s,d.refreshToken,e),M(s,d.accessToken,e),s.setHeader("X-New-Access-Token",d.accessToken),r.user=d.user,t();}catch{t(new i("UNAUTHORIZED","Session expired. Please login again."));}}else t(o);}}}function X(...e){let r=`Requires one of roles: ${e.join(", ")}`;return (s,t,n)=>{if(!s.user)return n(new i("UNAUTHORIZED","Not authenticated"));let o=s.user.roles;if(!e.some(c=>o.includes(c)))return n(new i("FORBIDDEN",r));n();}}var fr=new i("FORBIDDEN","You do not have permission to perform this action");function V(e){let r=typeof e=="function"?{check:e}:e;return async(s,t,n)=>{if(!s.user)return n(new i("UNAUTHORIZED","Not authenticated"));if(r.roles&&r.roles.length>0){let o=s.user.roles;if(r.roles.some(c=>o.includes(c)))return n()}try{let o=r.check(s);(o instanceof Promise?await o:o)?n():n(fr);}catch(o){n(o);}}}function P(e){return (r,s,t,n)=>{if(r instanceof i){t.status(r.statusCode).json({error:true,statusCode:r.statusCode,code:r.code,message:r.message,data:null});return}e?.onUnhandled?.(r),t.status(500).json({error:true,statusCode:500,code:"INTERNAL_SERVER_ERROR",message:"Internal server error",data:null});}}var oe=8,O=72,N=255;function y(e){return new i("VALIDATION_ERROR",e)}function T(e,r,s,t){e.status(r).json({error:false,statusCode:r,message:s,data:t});}function U(e,r){e.status(r.statusCode).json({error:true,statusCode:r.statusCode,code:r.code,message:r.message,data:null});}function B(e){if(e==null||typeof e!="object"||Array.isArray(e))throw new i("VALIDATION_ERROR","Request body is missing or not a JSON object. Did you apply express.json()?");return e}function hr(e,r){if(!r.apiKey)return;let s=e.headers["x-api-key"];if(typeof s!="string"||s!==r.apiKey)throw new i("UNAUTHORIZED","Invalid or missing API key")}function ge(e){if(e)try{let r=e();r instanceof Promise&&r.catch(()=>{});}catch{}}function mr(e){return e.startsWith("RS")||e.startsWith("PS")}function We(e){let r=Router(),s=e,t=p(s),n=e.router?.register??(a=>G(a,s)),o=e.router?.login??(a=>Q(a,s)),c=e.router?.refresh??(a=>_(a,s)),d=e.router?.logout??(a=>a!==void 0?ee(a,s):Promise.resolve()),g=e.router?.logoutAll??(a=>re(a,s)),m=e.router?.assignRoles??((a,u)=>ne(a,u,s)),w=e.router?.changeIdentifier??((a,u)=>te(a,u,s)),v=e.router?.changePassword??((a,u,R)=>se(a,u,R,s));mr(t.algorithm)&&r.get("/keys",(a,u)=>{u.setHeader("Cache-Control","public, max-age=3600"),u.json(ke(e.secret));}),r.post("/register",async(a,u,R)=>{try{hr(a,e);let l=B(a.body),{identifier:f,password:h,roles:A}=l;if(typeof f!="string"||f.trim().length===0)throw y("identifier is required and must be a non-empty string");if(f.length>N)throw y(`identifier must not exceed ${N} characters`);if(typeof h!="string"||h.length<oe)throw y(`password is required and must be at least ${oe} characters`);if(h.length>O)throw y(`password must not exceed ${O} characters`);if(A!==void 0&&!Array.isArray(A))throw y("roles must be an array of strings when provided");if(Array.isArray(A)&&!A.every(tr=>typeof tr=="string"))throw y("each role must be a string");let C=Array.isArray(A)?A:void 0,ie=C!==void 0?{identifier:f.trim(),password:h,roles:C}:{identifier:f.trim(),password:h},ae=await n(ie);if(!ae.success){U(u,ae.error);return}T(u,201,"User registered successfully",{user:ae.user});}catch(l){R(l);}}),r.post("/login",async(a,u,R)=>{try{let l=B(a.body),{identifier:f,password:h}=l;if(typeof f!="string"||f.trim().length===0)throw y("identifier is required and must be a non-empty string");if(f.length>N)throw y(`identifier must not exceed ${N} characters`);if(typeof h!="string"||h.length===0)throw y("password is required");if(h.length>O)throw y(`password must not exceed ${O} characters`);let A=f.trim(),C=await o({identifier:A,password:h});if(!C.success){ge(()=>e.hooks?.onFailedLogin?.(A,C.error)),U(u,C.error);return}ge(()=>e.hooks?.onLogin?.(C.user)),q(u,C.refreshToken,e),M(u,C.accessToken,e),T(u,200,"Login successful",{accessToken:C.accessToken,user:C.user});}catch(l){R(l);}}),r.post("/refresh",async(a,u,R)=>{try{let l=b(a.headers.cookie,E(e));if(!l)throw new i("UNAUTHORIZED","Refresh token cookie is missing");let f=await c(l);if(!f.success){W(u,e),U(u,f.error);return}q(u,f.refreshToken,e),M(u,f.accessToken,e),T(u,200,"Token refreshed",{accessToken:f.accessToken});}catch(l){R(l);}}),r.post("/logout",async(a,u,R)=>{try{let l=b(a.headers.cookie,E(e));await d(l),W(u,e),ue(u,e),T(u,200,"Logged out",null);}catch(l){R(l);}}),r.post("/logout-all",S(e),async(a,u,R)=>{try{let l=a.user.id;await g(l),ge(()=>e.hooks?.onLogout?.(l)),W(u,e),ue(u,e),T(u,200,"All sessions revoked",null);}catch(l){R(l);}}),r.get("/me",S(e),(a,u)=>{T(u,200,"OK",a.user);});let I=V(a=>!!a.user);return r.patch("/me/identifier",S(e),I,async(a,u,R)=>{try{let l=B(a.body),{newIdentifier:f}=l;if(typeof f!="string"||f.trim().length===0)throw y("newIdentifier is required and must be a non-empty string");if(f.length>N)throw y(`newIdentifier must not exceed ${N} characters`);let h=await w(a.user.id,f);if(!h.success){U(u,h.error);return}T(u,200,"Identifier updated successfully",{user:h.user});}catch(l){R(l);}}),r.patch("/me/password",S(e),I,async(a,u,R)=>{try{let l=B(a.body),{currentPassword:f,newPassword:h}=l;if(typeof f!="string"||f.length===0)throw y("currentPassword is required");if(typeof h!="string"||h.length<oe)throw y(`newPassword must be at least ${oe} characters`);if(h.length>O)throw y(`newPassword must not exceed ${O} characters`);if(f===h)throw y("newPassword must be different from currentPassword");let A=await v(a.user.id,f,h);if(!A.success){U(u,A.error);return}T(u,200,"Password updated successfully. All sessions have been revoked.",null);}catch(l){R(l);}}),r.post("/users/:userId/roles",S(e),X("admin"),async(a,u,R)=>{try{let l=B(a.body),{roles:f}=l,h=a.params.userId,A=typeof h=="string"?h:void 0;if(!A)throw y("userId is required");if(!Array.isArray(f)||f.length===0)throw y("roles must be a non-empty array of strings");if(!f.every(ie=>typeof ie=="string"))throw y("each role must be a string");let C=await m(A,f);if(!C.success){U(u,C.error);return}T(u,200,"Roles assigned successfully",{user:C.user});}catch(l){R(l);}}),r.use(P()),r}var Ze=new Map;function Ye(e){let r=Ze.get(e);return r||(r=new Redis(e,{lazyConnect:false,enableOfflineQueue:false}),Ze.set(e,r)),r}function he(e){let r=e?.ttl??3e5,s=(e?.header??"X-Idempotency-Key").toLowerCase(),t=new Set((e?.methods??["POST","PUT","PATCH"]).map(o=>o.toUpperCase())),n=e?.redisUrl;return n?Rr(n,r,s,t):yr(r,s,t,e?.maxSize??1e4)}function Rr(e,r,s,t){let n=Ye(e),o="sentri:idempotency:";return async(c,d,g)=>{let m=c.headers[s];if(!m||typeof m!="string"||!t.has(c.method))return g();c.requestId=m,d.setHeader("X-Request-Id",m);let w=await n.get(`${o}${m}`);if(w){let I=JSON.parse(w);return d.setHeader("X-Idempotent-Replayed","true"),d.status(I.statusCode).json(I.body)}let v=d.json.bind(d);d.json=function(a){if(d.statusCode>=200&&d.statusCode<300){let u={statusCode:d.statusCode,body:a,expiresAt:Date.now()+r};n.set(`${o}${m}`,JSON.stringify(u),"PX",r).catch(()=>{});}return v(a)},g();}}function yr(e,r,s,t){let n=Math.max(e,5e3),o=new Map,c=setInterval(()=>{let d=Date.now();for(let[g,m]of o)m.expiresAt<=d&&o.delete(g);},n);return typeof c=="object"&&c!==null&&"unref"in c&&c.unref(),(d,g,m)=>{let w=d.headers[r];if(!w||typeof w!="string"||!s.has(d.method))return m();d.requestId=w,g.setHeader("X-Request-Id",w);let v=Date.now(),I=o.get(w);if(I&&I.expiresAt>v)return g.setHeader("X-Idempotent-Replayed","true"),g.status(I.statusCode).json(I.body);let a=g.json.bind(g);g.json=function(R){if(g.statusCode>=200&&g.statusCode<300){if(o.size>=t){let l=o.keys().next().value;l!==void 0&&o.delete(l);}o.set(w,{statusCode:g.statusCode,body:R,expiresAt:Date.now()+e});}return a(R)},m();}}async function er(e){await e.schema.createTable("sentri_users").ifNotExists().addColumn("id","varchar(36)",r=>r.primaryKey().notNull()).addColumn("identifier","varchar(255)",r=>r.notNull().unique()).addColumn("password_hash","varchar(255)",r=>r.notNull()).addColumn("roles","text",r=>r.notNull().defaultTo("[]")).addColumn("created_at","timestamp",r=>r.notNull().defaultTo(sql`CURRENT_TIMESTAMP`)).execute(),await e.schema.createTable("sentri_sessions").ifNotExists().addColumn("id","varchar(36)",r=>r.primaryKey().notNull()).addColumn("user_id","varchar(36)",r=>r.notNull().references("sentri_users.id").onDelete("cascade")).addColumn("expires_at","timestamp",r=>r.notNull()).addColumn("created_at","timestamp",r=>r.notNull().defaultTo(sql`CURRENT_TIMESTAMP`)).execute();}function me(e){if(_e(e),e.mode==="client")return {protect:()=>S(e),authorize:(...t)=>X(...t),permit:t=>V(t),errorHandler:t=>P(t)};let r=e,s=p(r);return {protect:()=>S(r),authorize:(...t)=>X(...t),permit:t=>V(t),hashPassword:t=>D(t,s.saltRounds),verifyPassword:(t,n)=>K(t,n),signAccessToken:t=>j(t,r),signRefreshToken:t=>L(t,r),verifyAccessToken:t=>z(t,r),verifyRefreshToken:t=>F(t,r),getCurrentAccessToken:t=>$(t,r),router:()=>We(r),migrate:()=>er(k(r.dialect)),errorHandler:t=>P(t),idempotencyMiddleware:t=>he({...t,...r.redisUrl!==void 0&&{redisUrl:r.redisUrl}}),register:t=>G(t,r),login:t=>Q(t,r),refresh:t=>_(t,r),logout:t=>ee(t,r),logoutAll:t=>re(t,r),getUser:t=>ze(t,r),changeIdentifier:(t,n)=>te(t,n,r),changePassword:(t,n,o)=>se(t,n,o,r),assignRoles:(t,n)=>ne(t,n,r)}}function rr(e){return new PostgresDialect({pool:new Pool(e)})}function kr(e){let{privateKey:r}=generateKeyPairSync("rsa",{modulusLength:2048,privateKeyEncoding:{type:"pkcs8",format:"pem"},publicKeyEncoding:{type:"spki",format:"pem"}}),s={mode:"server",dialect:rr(e.db),secret:r,algorithm:"RS256",validRoles:e.validRoles,...e.accessExpiresIn!==void 0&&{accessExpiresIn:e.accessExpiresIn},...e.refreshExpiresIn!==void 0&&{refreshExpiresIn:e.refreshExpiresIn},...e.saltRounds!==void 0&&{saltRounds:e.saltRounds},...e.apiKey!==void 0&&{apiKey:e.apiKey},...e.cookie!==void 0&&{cookie:e.cookie},...e.accessCookie!==void 0&&{accessCookie:e.accessCookie},...e.hooks!==void 0&&{hooks:e.hooks},...e.router!==void 0&&{router:e.router},...e.isTokenRevoked!==void 0&&{isTokenRevoked:e.isTokenRevoked},...e.redisUrl!==void 0&&{redisUrl:e.redisUrl}};return me(s)}export{pe as SENTRI_ERROR_STATUS,i as SentriError,me as createAuth,kr as createAuthServer,P as createErrorHandler,he as createIdempotencyMiddleware,$ as getCurrentAccessToken,G as register};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "sentri",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "2.1.0",
|
|
4
4
|
"description": "Personal auth/authorization library for Express + Postgres",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"types": "dist/index.d.ts",
|
|
@@ -16,11 +16,11 @@
|
|
|
16
16
|
},
|
|
17
17
|
"files": [
|
|
18
18
|
"dist",
|
|
19
|
-
"bin"
|
|
20
|
-
"templates"
|
|
19
|
+
"bin"
|
|
21
20
|
],
|
|
22
21
|
"scripts": {
|
|
23
|
-
"build": "
|
|
22
|
+
"build": "tsup",
|
|
23
|
+
"build:types": "tsc --emitDeclarationOnly",
|
|
24
24
|
"test": "vitest run",
|
|
25
25
|
"test:watch": "vitest",
|
|
26
26
|
"test:coverage": "vitest run --coverage"
|
|
@@ -40,20 +40,22 @@
|
|
|
40
40
|
"@types/express": "^5.0.6",
|
|
41
41
|
"@types/jsonwebtoken": "^9.0.10",
|
|
42
42
|
"@types/node": "^22.20.0",
|
|
43
|
+
"@types/pg": "^8.11.10",
|
|
43
44
|
"@types/supertest": "^7.2.0",
|
|
44
45
|
"@vitest/coverage-v8": "^4.1.9",
|
|
45
46
|
"express": "^5.2.1",
|
|
46
47
|
"supertest": "^7.2.2",
|
|
48
|
+
"tsup": "^8.5.1",
|
|
47
49
|
"tsx": "^4.22.4",
|
|
48
50
|
"typescript": "^6.0.3",
|
|
49
51
|
"vitest": "^4.1.9"
|
|
50
52
|
},
|
|
51
53
|
"dependencies": {
|
|
52
|
-
"@prisma/adapter-pg": "^7.8.0",
|
|
53
|
-
"@prisma/client": "^7.8.0",
|
|
54
54
|
"bcrypt": "^6.0.0",
|
|
55
|
+
"ioredis": "^5.11.1",
|
|
55
56
|
"jsonwebtoken": "^9.0.3",
|
|
56
|
-
"
|
|
57
|
+
"kysely": "^0.27.4",
|
|
58
|
+
"pg": "^8.13.1"
|
|
57
59
|
},
|
|
58
60
|
"peerDependencies": {
|
|
59
61
|
"express": ">=4.0.0"
|
package/dist/cli.d.ts.map
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"cli.d.ts","sourceRoot":"","sources":["../src/cli.ts"],"names":[],"mappings":""}
|