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.
Files changed (64) hide show
  1. package/README.md +268 -448
  2. package/dist/cli.d.ts +0 -2
  3. package/dist/cli.js +113 -107
  4. package/dist/index.d.ts +545 -11
  5. package/dist/index.js +1 -5
  6. package/package.json +9 -7
  7. package/dist/cli.d.ts.map +0 -1
  8. package/dist/cli.js.map +0 -1
  9. package/dist/client.d.ts +0 -160
  10. package/dist/client.d.ts.map +0 -1
  11. package/dist/client.js +0 -45
  12. package/dist/client.js.map +0 -1
  13. package/dist/errors/AuthError.d.ts +0 -99
  14. package/dist/errors/AuthError.d.ts.map +0 -1
  15. package/dist/errors/AuthError.js +0 -97
  16. package/dist/errors/AuthError.js.map +0 -1
  17. package/dist/index.d.ts.map +0 -1
  18. package/dist/index.js.map +0 -1
  19. package/dist/libs/config.d.ts +0 -62
  20. package/dist/libs/config.d.ts.map +0 -1
  21. package/dist/libs/config.js +0 -97
  22. package/dist/libs/config.js.map +0 -1
  23. package/dist/libs/hash.d.ts +0 -17
  24. package/dist/libs/hash.d.ts.map +0 -1
  25. package/dist/libs/hash.js +0 -22
  26. package/dist/libs/hash.js.map +0 -1
  27. package/dist/libs/token.d.ts +0 -46
  28. package/dist/libs/token.d.ts.map +0 -1
  29. package/dist/libs/token.js +0 -118
  30. package/dist/libs/token.js.map +0 -1
  31. package/dist/middleware/authorize.d.ts +0 -18
  32. package/dist/middleware/authorize.d.ts.map +0 -1
  33. package/dist/middleware/authorize.js +0 -30
  34. package/dist/middleware/authorize.js.map +0 -1
  35. package/dist/middleware/errorHandler.d.ts +0 -71
  36. package/dist/middleware/errorHandler.d.ts.map +0 -1
  37. package/dist/middleware/errorHandler.js +0 -74
  38. package/dist/middleware/errorHandler.js.map +0 -1
  39. package/dist/middleware/permit.d.ts +0 -62
  40. package/dist/middleware/permit.d.ts.map +0 -1
  41. package/dist/middleware/permit.js +0 -61
  42. package/dist/middleware/permit.js.map +0 -1
  43. package/dist/middleware/protect.d.ts +0 -31
  44. package/dist/middleware/protect.d.ts.map +0 -1
  45. package/dist/middleware/protect.js +0 -54
  46. package/dist/middleware/protect.js.map +0 -1
  47. package/dist/middleware/router.d.ts +0 -34
  48. package/dist/middleware/router.d.ts.map +0 -1
  49. package/dist/middleware/router.js +0 -264
  50. package/dist/middleware/router.js.map +0 -1
  51. package/dist/services/auth.d.ts +0 -85
  52. package/dist/services/auth.d.ts.map +0 -1
  53. package/dist/services/auth.js +0 -173
  54. package/dist/services/auth.js.map +0 -1
  55. package/dist/types/auth.d.ts +0 -450
  56. package/dist/types/auth.d.ts.map +0 -1
  57. package/dist/types/auth.js +0 -21
  58. package/dist/types/auth.js.map +0 -1
  59. package/templates/drizzle/adapter.ts +0 -154
  60. package/templates/drizzle/auth.ts +0 -82
  61. package/templates/drizzle/schema.ts +0 -47
  62. package/templates/prisma/adapter.ts +0 -122
  63. package/templates/prisma/auth.ts +0 -85
  64. package/templates/prisma/schema.prisma +0 -56
package/dist/index.d.ts CHANGED
@@ -1,18 +1,552 @@
1
- import type { AuthUser } from './types/auth.js';
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
- export type { AuthConfig, CookieConfig, AuthUser, ApiResponse, AuthAdapter, UserRecord, SessionRecord, CreateUserData, RouterHandlers, RegisterInput, LoginInput, RegisterResult, AuthResult, RefreshResult, AssignRolesResult, } from './types/auth.js';
10
- export type { SentriErrorCode } from './errors/AuthError.js';
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": "1.1.2",
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": "tsc",
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
- "sentri": "^1.0.6"
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":""}