@gauts/auth 0.2.1 → 0.3.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 (63) hide show
  1. package/README.md +592 -186
  2. package/SECURITY.md +44 -1
  3. package/dist/adapters/hono/index.d.ts +40 -18
  4. package/dist/adapters/hono/index.d.ts.map +1 -1
  5. package/dist/adapters/hono/index.js +151 -54
  6. package/dist/adapters/hono/index.js.map +1 -1
  7. package/dist/adapters/next/index.d.ts +24 -0
  8. package/dist/adapters/next/index.d.ts.map +1 -0
  9. package/dist/adapters/next/index.js +90 -0
  10. package/dist/adapters/next/index.js.map +1 -0
  11. package/dist/adapters/prisma/index.d.ts +39 -0
  12. package/dist/adapters/prisma/index.d.ts.map +1 -0
  13. package/dist/adapters/prisma/index.js +205 -0
  14. package/dist/adapters/prisma/index.js.map +1 -0
  15. package/dist/auth.d.ts +9 -7
  16. package/dist/auth.d.ts.map +1 -1
  17. package/dist/auth.js +11 -13
  18. package/dist/auth.js.map +1 -1
  19. package/dist/client/index.d.ts +8 -3
  20. package/dist/client/index.d.ts.map +1 -1
  21. package/dist/client/index.js +23 -7
  22. package/dist/client/index.js.map +1 -1
  23. package/dist/config.d.ts +3 -3
  24. package/dist/config.d.ts.map +1 -1
  25. package/dist/config.js +12 -18
  26. package/dist/config.js.map +1 -1
  27. package/dist/errors.d.ts +2 -2
  28. package/dist/errors.d.ts.map +1 -1
  29. package/dist/errors.js +7 -5
  30. package/dist/errors.js.map +1 -1
  31. package/dist/index.d.ts +2 -1
  32. package/dist/index.d.ts.map +1 -1
  33. package/dist/password/index.d.ts.map +1 -1
  34. package/dist/password/index.js +2 -4
  35. package/dist/password/index.js.map +1 -1
  36. package/dist/session/cache.d.ts +32 -0
  37. package/dist/session/cache.d.ts.map +1 -0
  38. package/dist/session/cache.js +136 -0
  39. package/dist/session/cache.js.map +1 -0
  40. package/dist/session/cookie.d.ts +15 -0
  41. package/dist/session/cookie.d.ts.map +1 -0
  42. package/dist/session/cookie.js +31 -0
  43. package/dist/session/cookie.js.map +1 -0
  44. package/dist/session/guards.d.ts +6 -0
  45. package/dist/session/guards.d.ts.map +1 -0
  46. package/dist/session/guards.js +23 -0
  47. package/dist/session/guards.js.map +1 -0
  48. package/dist/session/service.d.ts +3 -4
  49. package/dist/session/service.d.ts.map +1 -1
  50. package/dist/session/service.js +148 -187
  51. package/dist/session/service.js.map +1 -1
  52. package/dist/session/token.js.map +1 -1
  53. package/dist/session/types.d.ts +65 -74
  54. package/dist/session/types.d.ts.map +1 -1
  55. package/package.json +91 -92
  56. package/dist/adapters/redis/index.d.ts +0 -11
  57. package/dist/adapters/redis/index.d.ts.map +0 -1
  58. package/dist/adapters/redis/index.js +0 -62
  59. package/dist/adapters/redis/index.js.map +0 -1
  60. package/dist/session/schema.d.ts +0 -5
  61. package/dist/session/schema.d.ts.map +0 -1
  62. package/dist/session/schema.js +0 -56
  63. package/dist/session/schema.js.map +0 -1
package/README.md CHANGED
@@ -1,299 +1,705 @@
1
1
  # `@gauts/auth`
2
2
 
3
- Reusable password and opaque server-side session authentication for Node.js applications.
3
+ Reusable password authentication and database-backed opaque sessions for Node.js applications.
4
4
 
5
- `@gauts/auth` validates sessions through Redis and records session history through an application-owned database adapter. It does not create endpoints, database models, users, or application authorization rules.
5
+ The package stores only a SHA-256 token hash in the database. Browser integration uses three cookies with separate responsibilities:
6
6
 
7
- ## Design
7
+ ```text
8
+ session cookie -> opaque token
9
+ cache cookie -> short signed session snapshot
10
+ renew cookie -> untrusted renewal marker
11
+ ```
12
+
13
+ Only the opaque session token authenticates. It remains stable during sliding renewal, but the session can never exceed its configured maximum lifetime. The optional cache is disabled when omitted and never replaces database validation for unsafe methods, renewal, logout, WebSockets, or direct core calls.
8
14
 
9
- - Argon2id by default, with explicit bcrypt support.
10
- - One stable 256-bit opaque browser token per session.
11
- - SHA-256 token hashes in Redis and the session records database.
12
- - Redis-authoritative authentication with no database fallback.
13
- - Sliding inactivity expiration without an absolute lifetime.
14
- - Configurable client validation, using the complete User-Agent by default.
15
- - Framework integrations through explicit package exports.
15
+ ## Requirements
16
+
17
+ - Node.js 22 or newer.
18
+ - A database adapter, or a Prisma client containing the required schema contract.
19
+ - Hono 4 when using `@gauts/auth/hono`.
20
+ - Next.js 15 or newer when using `@gauts/auth/next`.
16
21
 
17
22
  ## Installation
18
23
 
24
+ For Hono and Prisma:
25
+
19
26
  ```bash
20
- npm install @gauts/auth redis hono
27
+ npm install @gauts/auth hono @prisma/client
21
28
  ```
22
29
 
23
- ## Setup
30
+ For the Next.js adapter, the application must already use Next.js:
24
31
 
25
- ```ts
26
- import { createAuth, type SessionRecords } from "@gauts/auth";
27
- import { createHonoAdapter } from "@gauts/auth/hono";
28
- import { createRedisStore } from "@gauts/auth/redis";
29
-
30
- const records: SessionRecords = {
31
- create: (session) => db.sessions.create(session),
32
- find: ({ accountId, sessionId }) =>
33
- db.sessions.find({ accountId, sessionId }),
34
- findActive: ({ accountId, now }) =>
35
- db.sessions.findActive({ accountId, now }),
36
- revoke: ({ revokedAt, sessionIds }) =>
37
- db.sessions.revoke({ revokedAt, sessionIds }),
38
- updateExpiry: ({ expiresAt, sessionId, updatedAt }) =>
39
- db.sessions.updateExpiry({ expiresAt, sessionId, updatedAt }),
40
- };
32
+ ```bash
33
+ npm install @gauts/auth next
34
+ ```
41
35
 
42
- type AccountSession = {
43
- email: string;
44
- role: "owner" | "admin";
45
- };
36
+ `hono` and `next` are optional peer dependencies. The Prisma adapter receives the consuming application's generated client and does not import Prisma at runtime.
46
37
 
47
- const auth = createAuth<AccountSession>({
48
- redis: createRedisStore({ client: redis, config: { prefix: "my-app:auth" } }),
49
- records,
50
- session: {
51
- ttl: 60 * 60 * 24 * 7,
52
- renewInterval: 60 * 60 * 24,
53
- max: 10,
54
- validation: ["userAgent"],
55
- },
56
- });
38
+ ## Package entry points
57
39
 
58
- const hono = createHonoAdapter({
59
- auth,
60
- getIp: (c) => getTrustedClientIp(c),
61
- });
40
+ | Import | Purpose |
41
+ | --- | --- |
42
+ | `@gauts/auth` | Framework-independent password and session core types. |
43
+ | `@gauts/auth/prisma` | Prisma implementation of the database adapter. |
44
+ | `@gauts/auth/hono` | Hono cookies, session methods, and middleware. |
45
+ | `@gauts/auth/next` | Next.js renewal scheduling and `Set-Cookie` forwarding. |
46
+
47
+ ## Flow
48
+
49
+ Login:
50
+
51
+ ```text
52
+ credentials accepted
53
+ -> generate 256-bit opaque token
54
+ -> store SHA-256 token hash in DB
55
+ -> load current account and user relations
56
+ -> validate configured status and role rules
57
+ -> write opaque session token cookie
58
+ -> write 24-hour renewal marker
59
+ -> optionally write signed short cache
62
60
  ```
63
61
 
64
- The application must obtain the client IP according to its own trusted-proxy configuration. The package canonicalizes the supplied IPv4 or IPv6 value but never decides which forwarding headers are trusted.
62
+ Protected `GET` or `HEAD`:
65
63
 
66
- ## Client information
64
+ ```text
65
+ opaque session token
66
+ -> valid signed cache bound to token and client?
67
+ -> yes: expose cached session, account, and user
68
+ -> no: perform indexed DB validation and issue a fresh cache
69
+ ```
67
70
 
68
- The application supplies `getIp` because only its deployment knows which proxies and forwarding
69
- headers are trusted. The Hono adapter reads User-Agent and platform directly from the request.
71
+ Unsafe methods and direct core calls:
70
72
 
71
- The resulting client information has this shape:
73
+ ```text
74
+ opaque session token
75
+ -> SHA-256 token
76
+ -> indexed DB lookup
77
+ -> expiry, revocation, account, user, and client validation
78
+ -> clear any existing cache after successful unsafe validation
79
+ -> route
80
+ ```
72
81
 
73
- ```ts
74
- type SessionClientInput = {
75
- ip?: string | null;
76
- userAgent?: string | null;
77
- platform?: string | null;
78
- };
82
+ Renewal:
83
+
84
+ ```text
85
+ Next checks the renewal marker
86
+ -> marker exists: no API call
87
+ -> marker missing: POST /auth/renew
88
+ -> API performs full DB validation
89
+ -> DB expires_at = min(now + ttl, created_at + maxLifetime)
90
+ -> Set-Cookie with same token, a new marker, and a fresh cache
79
91
  ```
80
92
 
81
- - `validation` selects which client fields are compared on authenticated requests.
82
- - `userAgent` is compared by default.
83
- - `ip` and `platform` are always stored as session metadata, even when they are not compared.
84
- - IPv4, IPv4-mapped IPv6, and IPv6 values are canonicalized by the package.
85
- - Platform is unquoted, trimmed, and limited to 255 characters.
86
- - User-Agent is stored and compared in full without truncation.
93
+ `auth.session.resolve()` is always DB-backed and read-only. Only the explicit renewal operation updates database expiry, and neither activity nor renewal can extend the session beyond `maxLifetime`.
87
94
 
88
- `getIp` runs during session creation and on every authenticated request. It should only resolve the
89
- trusted request IP and must not perform unrelated account or database work.
95
+ ## Prisma schema contract
90
96
 
91
- ## Passwords
97
+ The Prisma adapter resolves authentication through this relation chain:
92
98
 
93
- Argon2id is the default:
99
+ ```text
100
+ account_sessions -> account -> user
101
+ ```
102
+
103
+ The default Prisma client delegate is `account_sessions`. A different session model delegate can be selected through `config.table`.
104
+
105
+ ### Complete minimal schema
106
+
107
+ This is a complete MySQL/MariaDB example. If `users` and `user_accounts` already exist, merge the required fields and relations into those models instead of duplicating them.
108
+
109
+ ```prisma
110
+ model users {
111
+ id String @id @default(uuid()) @db.VarChar(255)
112
+ role String @db.VarChar(255)
113
+ status String @db.VarChar(255)
114
+
115
+ accounts user_accounts[]
116
+ }
117
+
118
+ model user_accounts {
119
+ id String @id @default(uuid()) @db.VarChar(255)
120
+ user_id String @db.VarChar(255)
121
+ email String @db.VarChar(255)
122
+ name String @db.VarChar(255)
123
+ role String @db.VarChar(255)
124
+ status String @db.VarChar(255)
125
+ timezone String? @db.VarChar(255)
126
+
127
+ user users @relation(fields: [user_id], references: [id], onDelete: Cascade)
128
+ sessions account_sessions[]
129
+
130
+ @@index([user_id])
131
+ }
132
+
133
+ model account_sessions {
134
+ id String @id @default(uuid()) @db.VarChar(255)
135
+ account_id String @db.VarChar(255)
136
+ token_hash String @unique @db.VarChar(64)
137
+ ip String? @db.VarChar(45)
138
+ platform String? @db.VarChar(255)
139
+ agent String? @db.Text
140
+ expires_at DateTime @db.Timestamp(0)
141
+ revoked_at DateTime? @db.Timestamp(0)
142
+ created_at DateTime @default(now()) @db.Timestamp(0)
143
+ updated_at DateTime? @db.Timestamp(0)
144
+
145
+ account user_accounts @relation(fields: [account_id], references: [id], onDelete: Cascade)
146
+
147
+ @@index([account_id])
148
+ @@index([expires_at])
149
+ @@index([revoked_at])
150
+ }
151
+ ```
152
+
153
+ The adapter requires these Prisma field and relation names:
154
+
155
+ | Path | Required fields |
156
+ | --- | --- |
157
+ | Session model | `id`, `account_id`, `token_hash`, `ip`, `platform`, `agent`, `expires_at`, `revoked_at`, `created_at`, `updated_at` |
158
+ | `account` relation | `id`, `email`, `name`, `role`, `status`, `timezone` |
159
+ | `account.user` relation | `id`, `role`, `status` |
160
+
161
+ The relation fields must be named `account` and `user`, because those are the names selected by the adapter. Prisma also requires the inverse relations; their field names (`sessions` and `accounts` above) can be changed because the adapter never queries them.
162
+
163
+ Application models may contain additional fields, defaults, indexes, and relations. `role` and `status` may use application-specific Prisma enums instead of `String`; Prisma returns both as strings to the adapter. For other database providers, replace the native `@db.*` annotations with compatible types and keep `agent` large enough to store the complete User-Agent.
164
+
165
+ `config.table` is the Prisma client delegate name, not the physical database table name. For example, a Prisma model named `AdminSession` mapped with `@@map("account_sessions")` normally uses the `adminSession` delegate.
166
+
167
+ Create migrations through the consuming application's normal Prisma workflow. The package never creates or runs migrations.
168
+
169
+ ## Hono quick start
170
+
171
+ The Hono adapter provides methods and middleware; it does not register routes automatically. The application remains responsible for creating its login, renewal, logout, and protected endpoints.
172
+
173
+ ### Create one auth instance
94
174
 
95
175
  ```ts
96
- const hash = await auth.password.hash(password);
97
- const valid = await auth.password.verify({ password, storedHash: hash });
176
+ import { createHonoAuth } from "@gauts/auth/hono";
177
+ import { createPrismaAdapter } from "@gauts/auth/prisma";
178
+
179
+ export const auth = createHonoAuth({
180
+ secret: process.env.AUTH_SECRET,
181
+
182
+ db: createPrismaAdapter({
183
+ client: prisma,
184
+ config: {
185
+ account: {
186
+ status: ["ACTIVE", "PENDING"],
187
+ },
188
+ user: {
189
+ status: ["ACTIVE", "PENDING"],
190
+ },
191
+ },
192
+ }),
193
+
194
+ getIp: (c) => getTrustedClientIp(c),
195
+
196
+ session: {
197
+ validation: ["agent"],
198
+ },
199
+
200
+ cache: {
201
+ ttl: 60,
202
+ },
203
+ });
98
204
  ```
99
205
 
100
- Applications whose database contains bcrypt hashes select bcrypt explicitly:
206
+ `secret` is required only when `cache` is configured. Supply at least 32 high-entropy bytes from the API environment. It is never shared with the Next.js application.
207
+
208
+ `createHonoAuth` is the normal Hono entry point: it creates the framework-independent core and attaches the Hono methods in one object. Use `createAuth` plus `createHonoAdapter` only when the same core instance must be composed manually:
101
209
 
102
210
  ```ts
103
- const auth = createAuth({
104
- password: {
105
- algorithm: "bcrypt",
106
- },
107
- redis: createRedisStore({ client: redis }),
108
- records,
211
+ import { createAuth } from "@gauts/auth";
212
+ import { createHonoAdapter } from "@gauts/auth/hono";
213
+
214
+ const core = createAuth({ db });
215
+ const hono = createHonoAdapter({
216
+ auth: core,
217
+ cache: { ttl: 60 },
218
+ getIp: (c) => getTrustedClientIp(c),
219
+ secret: process.env.AUTH_SECRET,
109
220
  });
110
221
  ```
111
222
 
112
- New bcrypt hashes reject passwords above 72 bytes. Applications with historical hashes created from longer bcrypt input can opt into a larger verification-only boundary without permitting new truncated hashes:
223
+ Do not create a second core for the adapter; pass the existing `core` instance through `auth`.
224
+
225
+ Only `account_id` is stored in the session row. The Prisma adapter loads the current `account` and `user` relations during authentication; no dynamic account data is copied into the session.
226
+
227
+ ### Type the application
113
228
 
114
229
  ```ts
115
- password: {
116
- algorithm: "bcrypt",
117
- maxBytes: 72,
118
- verifyMaxBytes: 1024,
119
- }
230
+ import { Hono } from "hono";
231
+ import type { HonoAuthEnv } from "@gauts/auth/hono";
232
+
233
+ const app = new Hono<HonoAuthEnv>();
120
234
  ```
121
235
 
122
- The configured algorithm is used for both hashing and verification. The package does not detect, convert, migrate, or fall back to another algorithm.
236
+ `auth.requireSession` installs:
123
237
 
124
- ## Login
238
+ ```ts
239
+ const session = c.get("session");
240
+ const account = c.get("account");
241
+ const user = c.get("user");
242
+ ```
125
243
 
126
- The application owns the endpoint and account lookup:
244
+ The values have these shapes:
127
245
 
128
246
  ```ts
129
- app.post("/auth/login", async (c) => {
130
- const { email, password } = await c.req.json();
131
- const account = await findAccount(email);
132
-
133
- if (
134
- !account ||
135
- !(await auth.password.verify({
136
- password,
137
- storedHash: account.passwordHash,
138
- }))
139
- ) {
140
- return c.json({ error: "Invalid credentials." }, 401);
141
- }
142
-
143
- const session = await hono.createSession({
144
- accountId: account.id,
145
- context: c,
146
- data: {
147
- email: account.email,
148
- role: account.role,
149
- },
150
- });
247
+ type AuthAccount = {
248
+ email: string;
249
+ id: string;
250
+ name: string;
251
+ role: string;
252
+ status: string;
253
+ timezone: string | null;
254
+ user: AuthUser;
255
+ };
151
256
 
152
- return c.json({ account: session.data });
153
- });
257
+ type AuthUser = {
258
+ id: string;
259
+ role: string;
260
+ status: string;
261
+ };
262
+
263
+ type Session = {
264
+ account_id: string;
265
+ client: {
266
+ agent: string | null;
267
+ ip: string | null;
268
+ platform: string | null;
269
+ };
270
+ created_at: Date;
271
+ expires_at: Date;
272
+ id: string;
273
+ renew_at: Date;
274
+ };
154
275
  ```
155
276
 
156
- Applications must apply their own login rate limiting and account-enumeration protection.
277
+ ### Login
157
278
 
158
- ## Protected routes
279
+ The application owns input validation, credential lookup, rate limiting, and error responses. The adapter access rules validate current account and user status/role before the session is accepted.
159
280
 
160
281
  ```ts
161
- app.get("/account", hono.requireSession, async (c) => {
162
- const session = c.get("session");
163
- const account = await findAccountById(session.accountId);
282
+ const DUMMY_PASSWORD_HASH =
283
+ "$argon2id$v=19$m=65536,p=4,t=3$PUotpfVXonc0VRFuV1pKZQ$oxxA8DMvGRTSbZvh2Dkokeyih9sbKeodWYROqVxP9BI";
164
284
 
165
- return c.json({ account });
285
+ app.post("/auth/login", async (c) => {
286
+ const body = await c.req.json<{
287
+ email: string;
288
+ password: string;
289
+ }>();
290
+ const account = await findAccount(body.email);
291
+ const passwordValid = await auth.password.verify({
292
+ password: body.password,
293
+ storedHash: account?.passwordHash ?? DUMMY_PASSWORD_HASH,
294
+ });
295
+
296
+ if (!account || !passwordValid) {
297
+ return c.json({ error: "Invalid credentials." }, 401);
298
+ }
299
+
300
+ await auth.createSession({
301
+ account_id: account.id,
302
+ context: c,
303
+ });
304
+
305
+ return c.json({ authenticated: true });
166
306
  });
167
307
  ```
168
308
 
169
- The middleware reads the cookie, resolves Redis, compares the configured client fields, renews expiry when due, and places the typed session in the Hono context. It does not load application data from the database.
309
+ The dummy hash ensures that unknown accounts still perform the configured password verification. Precompute it once with the same algorithm and cost as the application; never generate it inside the request handler. Keep the response identical for unknown accounts and invalid passwords.
310
+
311
+ Only `account_id` is persisted by the session. Email, roles, statuses, password hashes, and other dynamic account data never enter the session table.
170
312
 
171
- Applications that need to populate additional Hono context variables can resolve through the same adapter without duplicating its cookie behavior:
313
+ ### Protect routes
172
314
 
173
315
  ```ts
174
- const requireAccount = async (c, next) => {
175
- const session = await hono.resolveSession(c);
316
+ app.get("/account", auth.requireSession, (c) => {
317
+ return c.json({
318
+ account: c.get("account"),
319
+ session_id: c.get("session").id,
320
+ user: c.get("user"),
321
+ });
322
+ });
323
+ ```
176
324
 
177
- c.set("account", session.data);
178
- await next();
179
- };
325
+ `requireSession` authenticates the request. With cache configured, only `GET` and `HEAD` may use it. Every other method validates through the database and clears the short cache after successful validation. It does not apply application-specific route roles or permissions.
326
+
327
+ ### Renewal endpoint
328
+
329
+ ```ts
330
+ app.post("/auth/renew", async (c) => {
331
+ await auth.renewSession(c);
332
+ return c.body(null, 204);
333
+ });
180
334
  ```
181
335
 
182
- ## Logout
336
+ `renewSession` always performs full database validation. It independently derives whether renewal is due from `created_at` and the last database renewal; the renewal marker never authorizes renewal.
337
+
338
+ If renewal is not yet due, database expiry is not changed. The API still returns the authoritative session cookie, renewal marker, and cache.
339
+
340
+ ### Logout
183
341
 
184
342
  ```ts
185
343
  app.post("/auth/logout", async (c) => {
186
- await hono.revokeSession(c);
187
- return c.body(null, 204);
344
+ await auth.revokeSession(c);
345
+ return c.body(null, 204);
188
346
  });
189
347
  ```
190
348
 
191
- The adapter clears the cookie only after backend revocation succeeds. Infrastructure errors are
192
- propagated so the application can return its own service-error response.
349
+ Logout marks the database session as revoked and then clears all three cookies. If database revocation fails, the cookies are not cleared and the error is propagated.
350
+
351
+ ## Next.js renewal adapter
193
352
 
194
- ## Active sessions
353
+ The Next.js adapter does not authenticate sessions. It calls the private API renewal URL only when the renewal marker is missing.
195
354
 
196
355
  ```ts
197
- const sessions = await auth.session.list(accountId);
356
+ import { createNextAuth } from "@gauts/auth/next";
198
357
 
199
- await auth.session.revoke({
200
- accountId,
201
- sessionId,
358
+ export const nextAuth = createNextAuth({
359
+ renewUrl: `${process.env.NEXT_PRIVATE_API_URL}/auth/renew`,
202
360
  });
203
-
204
- await auth.session.revokeAccount(accountId);
205
361
  ```
206
362
 
207
- The records database supplies session history. Redis is checked in a batch before a session is reported as active. Token hashes are never returned by `list`.
363
+ The controlled header list is also exported for application fetchers that need to follow the same forwarding policy:
208
364
 
209
- ## Synchronizing session data
365
+ ```ts
366
+ import { FORWARD_HEADERS } from "@gauts/auth/next";
367
+ ```
210
368
 
211
- When cached session claims change:
369
+ Use it in `proxy.ts` before returning the browser-facing response:
212
370
 
213
371
  ```ts
214
- await auth.session.sync({
215
- accountId,
216
- data: {
217
- email: account.email,
218
- role: account.role,
219
- },
220
- });
372
+ import type { NextRequest } from "next/server";
373
+ import { NextResponse } from "next/server";
374
+
375
+ export const proxy = async (request: NextRequest) => {
376
+ const response = NextResponse.next();
377
+ const renewal = await nextAuth.renew({
378
+ request,
379
+ response,
380
+ });
381
+
382
+ if (renewal.status === 401) {
383
+ return NextResponse.redirect(new URL("/login", request.url));
384
+ }
385
+
386
+ if (renewal.status !== null && renewal.status >= 500) {
387
+ return NextResponse.redirect(new URL("/maintenance", request.url));
388
+ }
389
+
390
+ return renewal.response;
391
+ };
221
392
  ```
222
393
 
223
- Synchronization preserves the existing token and Redis TTL.
394
+ Result semantics:
395
+
396
+ | `attempted` | `status` | Meaning |
397
+ | --- | ---: | --- |
398
+ | `false` | `null` | Session token and renewal marker exist. |
399
+ | `false` | `401` | Session token is missing or malformed. No API call occurred. |
400
+ | `true` | HTTP status | `/auth/renew` was called and its `Set-Cookie` headers were copied. |
224
401
 
225
- ## Session records contract
402
+ The adapter forwards only the session cookie and the client/origin headers required for session and CSRF validation. Other cookies, `Authorization`, and arbitrary request headers are never forwarded. Renewal rejects redirects and times out after five seconds. `renewUrl` must point to the application's trusted private API.
226
403
 
227
- Each consuming application maps `SessionRecords` to its database. The durable record contains:
404
+ Protected API endpoints remain responsible for real authentication. The renewal marker is only a browser scheduling mechanism and never acts as a refresh token.
228
405
 
229
- - Session ID.
230
- - Account ID and relation owned by the application.
231
- - SHA-256 token hash.
232
- - Canonical IP.
233
- - Platform metadata.
234
- - Complete User-Agent.
235
- - Creation, current expiry, update, and revocation timestamps.
406
+ Apply the proxy only to protected application routes, or skip renewal handling for public routes before calling `nextAuth.renew`. Otherwise a missing cookie produces `status: 401`, which is correct for protected routes but not for login or public pages.
236
407
 
237
- The database never stores the raw browser token.
408
+ ## Configuration
238
409
 
239
- ## Expiration
410
+ ### Password
240
411
 
241
- Defaults:
412
+ Argon2id is the default:
242
413
 
243
414
  ```ts
244
- session: {
245
- ttl: 7 * 24 * 60 * 60,
246
- renewInterval: 24 * 60 * 60,
247
- max: 10,
248
- validation: ["userAgent"],
415
+ password: {
416
+ algorithm: "argon2id",
249
417
  }
250
418
  ```
251
419
 
252
- - Inactive sessions expire after seven days.
253
- - Active sessions renew at most once every 24 hours.
254
- - Renewal keeps the same browser token.
255
- - There is no absolute session lifetime.
256
- - Every successful `resolve` call counts as HTTP activity and renews the session when due.
420
+ | Argon2id property | Default | Allowed |
421
+ | --- | ---: | ---: |
422
+ | `hashLength` | `32` | `16` to `64` |
423
+ | `maxBytes` | `1024` | `1` to `1,048,576` |
424
+ | `memoryCost` | `65,536` | `8,192` to `1,048,576` |
425
+ | `parallelism` | `4` | `1` to `16` |
426
+ | `timeCost` | `3` | `1` to `10` |
257
427
 
258
- Consumers that cannot deliver `Set-Cookie`, such as WebSocket handshakes, validate without renewing:
428
+ Applications with existing bcrypt hashes must select bcrypt explicitly:
259
429
 
260
430
  ```ts
261
- const session = await auth.session.validate({
262
- client,
263
- token,
264
- });
431
+ password: {
432
+ algorithm: "bcrypt",
433
+ }
265
434
  ```
266
435
 
267
- `validate` performs the same Redis, expiry, payload, and configured client-field checks as `resolve`. A client mismatch still revokes the backend session. It does not update `touchedAt`, Redis TTL, or the database expiry.
436
+ | bcrypt property | Default | Allowed |
437
+ | --- | ---: | ---: |
438
+ | `maxBytes` | `72` | `1` to `72` |
439
+ | `rounds` | `12` | `4` to `31` |
440
+ | `verifyMaxBytes` | `72` | `maxBytes` to `1,048,576` |
268
441
 
269
- ## Client validation
442
+ The selected algorithm is used for both hashing and verification. The package never detects algorithms, migrates hashes, rehashes passwords, or falls back to another algorithm.
270
443
 
271
- The default compares only the complete User-Agent:
444
+ ### Session
272
445
 
273
446
  ```ts
274
447
  session: {
275
- validation: ["userAgent"],
448
+ maxLifetime: 30 * 24 * 60 * 60,
449
+ renewInterval: 24 * 60 * 60,
450
+ ttl: 7 * 24 * 60 * 60,
451
+ validation: ["agent"],
276
452
  }
277
453
  ```
278
454
 
279
- Applications can choose any combination of `ip`, `userAgent`, and `platform`:
455
+ Time values are seconds.
456
+
457
+ | Property | Default | Allowed | Purpose |
458
+ | --- | ---: | ---: | --- |
459
+ | `maxLifetime` | `2,592,000` | `ttl` to `31,536,000` | Maximum lifetime from the original login, regardless of activity. |
460
+ | `renewInterval` | `86,400` | `1` to `ttl - 1` | Minimum interval before renewal is due. |
461
+ | `ttl` | `604,800` | `60` to `31,536,000` | Sliding inactivity lifetime. |
462
+ | `validation` | `["agent"]` | Unique `agent`, `ip`, `platform` fields | Exact client fields compared on every validation. |
463
+
464
+ The authoritative limits are derived from the immutable creation time and the last successful renewal:
465
+
466
+ ```text
467
+ maxExpiresAt = created_at + maxLifetime
468
+ expires_at = min(now + ttl, maxExpiresAt)
469
+ renew_at = min((updated_at ?? created_at) + renewInterval, maxExpiresAt)
470
+ ```
471
+
472
+ `maxLifetime` must be greater than or equal to `ttl`. It is enforced by the session core and requires a new login after the limit is reached; no additional database column is required.
473
+
474
+ ### Cookie
280
475
 
281
476
  ```ts
282
- session: {
283
- validation: ["ip", "userAgent", "platform"],
477
+ cookie: {
478
+ cacheName: "__cac",
479
+ domain: undefined,
480
+ name: "__sec",
481
+ path: "/",
482
+ renewName: "__ren",
483
+ sameSite: "Lax",
484
+ secure: true,
485
+ }
486
+ ```
487
+
488
+ `HttpOnly` is always enabled.
489
+
490
+ - The session cookie contains only the stable opaque token and expires with the database session.
491
+ - The cache cookie contains the signed snapshot and expires after `cache.ttl`.
492
+ - The renewal cookie contains the fixed value `1` and expires at the authoritative `renew_at` time.
493
+ - Cookie names default to `__sec`, `__cac`, and `__ren`.
494
+ - All three names must be valid and unique.
495
+
496
+ - `__Host-` requires `secure: true`, `path: "/"`, and no domain.
497
+ - `__Secure-` requires `secure: true`.
498
+ - `SameSite=None` requires `secure: true`.
499
+ - Local HTTP development requires `secure: false`.
500
+
501
+ ### Signed session cache
502
+
503
+ The cache is disabled by default. Enable it explicitly:
504
+
505
+ ```ts
506
+ secret: process.env.AUTH_SECRET,
507
+ cache: {
508
+ ttl: 60,
284
509
  }
285
510
  ```
286
511
 
287
- An empty array disables client-field comparison and validates only the session token. Unknown or duplicate fields are rejected during startup.
512
+ `ttl` is measured in seconds and must be an integer from `1` through the configured session TTL. The secret must contain at least 32 bytes and should be generated from a cryptographically secure source.
513
+
514
+ The cache payload:
515
+
516
+ - is signed with HMAC-SHA-256 using a domain-separated context;
517
+ - is cryptographically bound to the opaque session token;
518
+ - contains the resolved session, account, user, and its own expiry;
519
+ - compares the same configured IP, User-Agent, and platform fields on every hit;
520
+ - never extends the authoritative session expiry;
521
+ - is accepted only for `GET` and `HEAD` requests.
522
+
523
+ An absent, expired, malformed, altered, token-mismatched, or client-mismatched cache is a cache miss. The adapter then performs normal database authentication. A real configured client mismatch is therefore still detected and revoked by the database session service.
524
+
525
+ Unsafe methods always query the database. Renewal and logout also query the database directly. `auth.session.resolve()` never uses the browser cache, which keeps WebSocket and non-HTTP integrations DB-backed.
526
+
527
+ ### Trusted client IP
528
+
529
+ ```ts
530
+ getIp: (c) => getTrustedClientIp(c);
531
+ ```
532
+
533
+ Only the application knows which proxy and forwarding header are trusted. The package normalizes the returned value but never chooses `X-Forwarded-For`, `CF-Connecting-IP`, or a socket address itself.
534
+
535
+ The Hono adapter reads:
536
+
537
+ ```ts
538
+ type SessionClientInput = {
539
+ agent?: string | null;
540
+ ip?: string | null;
541
+ platform?: string | null;
542
+ };
543
+ ```
544
+
545
+ - IPv4, IPv4-mapped IPv6, and IPv6 are canonicalized.
546
+ - Invalid or empty IP values become `null`.
547
+ - Platform comes from `Sec-CH-UA-Platform`, is normalized, and is limited to 255 characters.
548
+ - User-Agent comes from `User-Agent` and is stored in full.
549
+ - No GeoIP, DNS, country, or external lookup is performed.
550
+ - Every field selected in `session.validation` is required during creation and validation. Missing or invalid configured fields never match.
551
+
552
+ ## Database adapter
553
+
554
+ The core depends only on the exported `DbAdapter` contract. It does not import Prisma or depend on a specific ORM. `createPrismaAdapter` is the Prisma implementation exposed through `@gauts/auth/prisma`; other ORM implementations can use their own package subpath and factory name.
555
+
556
+ Default Prisma delegate (`account_sessions`):
557
+
558
+ ```ts
559
+ import { createPrismaAdapter } from "@gauts/auth/prisma";
560
+
561
+ const db = createPrismaAdapter({
562
+ client: prisma,
563
+ config: {
564
+ account: {
565
+ status: ["ACTIVE"],
566
+ },
567
+ user: {
568
+ status: ["ACTIVE"],
569
+ },
570
+ },
571
+ });
572
+ ```
573
+
574
+ Custom compatible Prisma delegate:
575
+
576
+ ```ts
577
+ const db = createPrismaAdapter({
578
+ client: prisma,
579
+ config: {
580
+ account: {
581
+ status: ["ACTIVE"],
582
+ },
583
+ table: "admin_sessions",
584
+ user: {
585
+ status: ["ACTIVE"],
586
+ },
587
+ },
588
+ });
589
+ ```
590
+
591
+ Access rules:
592
+
593
+ ```ts
594
+ const db = createPrismaAdapter({
595
+ client: prisma,
596
+ config: {
597
+ account: {
598
+ status: ["ACTIVE", "PENDING"],
599
+ },
600
+ user: {
601
+ role: ["ADMIN"],
602
+ status: ["ACTIVE", "PENDING"],
603
+ },
604
+ },
605
+ });
606
+ ```
607
+
608
+ Account and user status lists are required because the package cannot know which application-specific values grant access. Roles are unrestricted unless configured. Every list must contain unique non-empty strings. A role rule controls authentication for the entire application; route-level authorization remains the application's responsibility.
609
+
610
+ The configured arrays are access allowlists, not declarations of every enum value that exists in the application.
611
+
612
+ Custom database adapters implement:
613
+
614
+ ```ts
615
+ import type { DbAdapter } from "@gauts/auth";
616
+
617
+ const db = {
618
+ create: async (session) => {},
619
+ find: async ({ account_id, session_id }) => null,
620
+ findActive: async ({ account_id, now }) => [],
621
+ findToken: async (token_hash) => null,
622
+ revoke: async ({ revoked_at, session_ids }) => {},
623
+ updateExpiry: async ({ expires_at, session_id, updated_at }) => {},
624
+ } satisfies DbAdapter;
625
+ ```
626
+
627
+ `findToken` must use the token hash and never accept or persist a raw token. It returns the current nested `account`, its `user`, and an `allowed` result derived from the adapter's access rules.
628
+
629
+ ## Core session API
630
+
631
+ ```ts
632
+ await auth.session.create({ account_id, client });
633
+ await auth.session.resolve({ client, token });
634
+ await auth.session.renew({ client, token });
635
+ await auth.session.list(account_id);
636
+ await auth.session.revoke({ account_id, session_id });
637
+ await auth.session.revokeToken(token);
638
+ await auth.session.revokeAccount(account_id);
639
+ ```
640
+
641
+ - `resolve` performs read-only authentication.
642
+ - `renew` validates and updates expiry only when due.
643
+ - `list` returns active, non-expired sessions without token hashes.
644
+ - revocation retains database history through `revoked_at`.
645
+ - the package does not limit session count or delete historical rows; retention and cleanup belong to the application.
288
646
 
289
- If a configured field differs, the package deletes the Redis session first and marks its database record as revoked. Clearing the response cookie is client cleanup; Redis deletion is what removes access.
647
+ ## Performance and cache policy
290
648
 
291
- Exact IP validation is opt-in because VPN, mobile, and other networks can change a legitimate user's public IP. Platform validation is also opt-in because the client-hint header may be absent. Client-field validation is defense in depth and does not replace TLS, secure cookies, CSRF protection, XSS prevention, or re-authentication for sensitive actions.
649
+ The package contains no Redis or in-process cache. The optional signed browser cache avoids shared infrastructure and works across API instances that use the same `AUTH_SECRET`.
292
650
 
293
- ## Package exports
651
+ Without cache, each `requireSession` performs:
294
652
 
295
653
  ```text
296
- @gauts/auth createAuth and public core types
297
- @gauts/auth/redis createRedisStore
298
- @gauts/auth/hono createHonoAdapter
654
+ 1 Prisma relation lookup by indexed account_sessions.token_hash
299
655
  ```
656
+
657
+ With a valid cache, `GET` and `HEAD` avoid that lookup until `cache.ttl` expires. Cache misses perform the normal indexed lookup and return a new cache cookie. Prisma loads current relations through the session query; the exact number of SQL statements depends on Prisma's configured relation load strategy.
658
+
659
+ Cookie caching has an explicit consistency tradeoff: revocation and account/user changes made elsewhere may remain visible to safe requests until the short cache expires. Unsafe methods never accept the cache, so writes observe current database state. A 60-second TTL limits the stale-read window to at most one minute.
660
+
661
+ Logout clears the current browser's cache immediately. Cookies on another device cannot be remotely deleted, so immediate cross-device read revocation requires disabling the cache or using shared server-side state outside this package.
662
+
663
+ ## Errors
664
+
665
+ ```ts
666
+ type AuthErrorCode =
667
+ | "AUTH_CONFIG_INVALID"
668
+ | "PASSWORD_INPUT_INVALID"
669
+ | "SESSION_CLIENT_MISMATCH"
670
+ | "SESSION_DATA_INVALID"
671
+ | "SESSION_INVALID"
672
+ | "SESSION_NOT_FOUND"
673
+ | "DB_UNAVAILABLE";
674
+ ```
675
+
676
+ Use `isAuthError(error)` before reading `error.code`.
677
+
678
+ The package does not choose HTTP responses. A typical application mapping is:
679
+
680
+ | Code | Suggested HTTP status |
681
+ | --- | ---: |
682
+ | `AUTH_CONFIG_INVALID` | `500` during startup |
683
+ | `PASSWORD_INPUT_INVALID` | `400` |
684
+ | `SESSION_CLIENT_MISMATCH` | `403` |
685
+ | `SESSION_DATA_INVALID` | `400` |
686
+ | `SESSION_INVALID` | `401` |
687
+ | `SESSION_NOT_FOUND` | `404` |
688
+ | `DB_UNAVAILABLE` | `503` |
689
+
690
+ Applications may use a different response policy, but database failures and invalid sessions must continue to fail closed.
691
+
692
+ ## Security responsibilities
693
+
694
+ The package provides session primitives, not a complete application security policy. Consuming applications remain responsible for:
695
+
696
+ - TLS and trusted-proxy configuration;
697
+ - CSRF, CORS, and origin validation;
698
+ - login and renewal rate limiting;
699
+ - equivalent password verification work for unknown accounts;
700
+ - account status and authorization rules;
701
+ - re-authentication for sensitive actions;
702
+ - database migrations and cleanup;
703
+ - never logging passwords, raw tokens, cookie headers, or password hashes.
704
+
705
+ Exact IP/User-Agent/platform matching is defense in depth. It does not prevent every stolen-cookie replay scenario.