@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.
- package/README.md +592 -186
- package/SECURITY.md +44 -1
- package/dist/adapters/hono/index.d.ts +40 -18
- package/dist/adapters/hono/index.d.ts.map +1 -1
- package/dist/adapters/hono/index.js +151 -54
- package/dist/adapters/hono/index.js.map +1 -1
- package/dist/adapters/next/index.d.ts +24 -0
- package/dist/adapters/next/index.d.ts.map +1 -0
- package/dist/adapters/next/index.js +90 -0
- package/dist/adapters/next/index.js.map +1 -0
- package/dist/adapters/prisma/index.d.ts +39 -0
- package/dist/adapters/prisma/index.d.ts.map +1 -0
- package/dist/adapters/prisma/index.js +205 -0
- package/dist/adapters/prisma/index.js.map +1 -0
- package/dist/auth.d.ts +9 -7
- package/dist/auth.d.ts.map +1 -1
- package/dist/auth.js +11 -13
- package/dist/auth.js.map +1 -1
- package/dist/client/index.d.ts +8 -3
- package/dist/client/index.d.ts.map +1 -1
- package/dist/client/index.js +23 -7
- package/dist/client/index.js.map +1 -1
- package/dist/config.d.ts +3 -3
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +12 -18
- package/dist/config.js.map +1 -1
- package/dist/errors.d.ts +2 -2
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +7 -5
- package/dist/errors.js.map +1 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/password/index.d.ts.map +1 -1
- package/dist/password/index.js +2 -4
- package/dist/password/index.js.map +1 -1
- package/dist/session/cache.d.ts +32 -0
- package/dist/session/cache.d.ts.map +1 -0
- package/dist/session/cache.js +136 -0
- package/dist/session/cache.js.map +1 -0
- package/dist/session/cookie.d.ts +15 -0
- package/dist/session/cookie.d.ts.map +1 -0
- package/dist/session/cookie.js +31 -0
- package/dist/session/cookie.js.map +1 -0
- package/dist/session/guards.d.ts +6 -0
- package/dist/session/guards.d.ts.map +1 -0
- package/dist/session/guards.js +23 -0
- package/dist/session/guards.js.map +1 -0
- package/dist/session/service.d.ts +3 -4
- package/dist/session/service.d.ts.map +1 -1
- package/dist/session/service.js +148 -187
- package/dist/session/service.js.map +1 -1
- package/dist/session/token.js.map +1 -1
- package/dist/session/types.d.ts +65 -74
- package/dist/session/types.d.ts.map +1 -1
- package/package.json +91 -92
- package/dist/adapters/redis/index.d.ts +0 -11
- package/dist/adapters/redis/index.d.ts.map +0 -1
- package/dist/adapters/redis/index.js +0 -62
- package/dist/adapters/redis/index.js.map +0 -1
- package/dist/session/schema.d.ts +0 -5
- package/dist/session/schema.d.ts.map +0 -1
- package/dist/session/schema.js +0 -56
- 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
|
|
3
|
+
Reusable password authentication and database-backed opaque sessions for Node.js applications.
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
The package stores only a SHA-256 token hash in the database. Browser integration uses three cookies with separate responsibilities:
|
|
6
6
|
|
|
7
|
-
|
|
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
|
-
|
|
10
|
-
|
|
11
|
-
-
|
|
12
|
-
-
|
|
13
|
-
-
|
|
14
|
-
-
|
|
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
|
|
27
|
+
npm install @gauts/auth hono @prisma/client
|
|
21
28
|
```
|
|
22
29
|
|
|
23
|
-
|
|
30
|
+
For the Next.js adapter, the application must already use Next.js:
|
|
24
31
|
|
|
25
|
-
```
|
|
26
|
-
|
|
27
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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
|
-
|
|
62
|
+
Protected `GET` or `HEAD`:
|
|
65
63
|
|
|
66
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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
|
-
|
|
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
|
-
|
|
89
|
-
trusted request IP and must not perform unrelated account or database work.
|
|
95
|
+
## Prisma schema contract
|
|
90
96
|
|
|
91
|
-
|
|
97
|
+
The Prisma adapter resolves authentication through this relation chain:
|
|
92
98
|
|
|
93
|
-
|
|
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
|
-
|
|
97
|
-
|
|
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
|
-
|
|
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
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
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
|
-
|
|
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
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
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
|
-
|
|
236
|
+
`auth.requireSession` installs:
|
|
123
237
|
|
|
124
|
-
|
|
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
|
|
244
|
+
The values have these shapes:
|
|
127
245
|
|
|
128
246
|
```ts
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
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
|
-
|
|
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
|
-
|
|
277
|
+
### Login
|
|
157
278
|
|
|
158
|
-
|
|
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
|
-
|
|
162
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
313
|
+
### Protect routes
|
|
172
314
|
|
|
173
315
|
```ts
|
|
174
|
-
|
|
175
|
-
|
|
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
|
-
|
|
178
|
-
|
|
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
|
-
|
|
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
|
-
|
|
187
|
-
|
|
344
|
+
await auth.revokeSession(c);
|
|
345
|
+
return c.body(null, 204);
|
|
188
346
|
});
|
|
189
347
|
```
|
|
190
348
|
|
|
191
|
-
|
|
192
|
-
|
|
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
|
-
|
|
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
|
-
|
|
356
|
+
import { createNextAuth } from "@gauts/auth/next";
|
|
198
357
|
|
|
199
|
-
|
|
200
|
-
|
|
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
|
|
363
|
+
The controlled header list is also exported for application fetchers that need to follow the same forwarding policy:
|
|
208
364
|
|
|
209
|
-
|
|
365
|
+
```ts
|
|
366
|
+
import { FORWARD_HEADERS } from "@gauts/auth/next";
|
|
367
|
+
```
|
|
210
368
|
|
|
211
|
-
|
|
369
|
+
Use it in `proxy.ts` before returning the browser-facing response:
|
|
212
370
|
|
|
213
371
|
```ts
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
408
|
+
## Configuration
|
|
238
409
|
|
|
239
|
-
|
|
410
|
+
### Password
|
|
240
411
|
|
|
241
|
-
|
|
412
|
+
Argon2id is the default:
|
|
242
413
|
|
|
243
414
|
```ts
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
renewInterval: 24 * 60 * 60,
|
|
247
|
-
max: 10,
|
|
248
|
-
validation: ["userAgent"],
|
|
415
|
+
password: {
|
|
416
|
+
algorithm: "argon2id",
|
|
249
417
|
}
|
|
250
418
|
```
|
|
251
419
|
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
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
|
-
|
|
428
|
+
Applications with existing bcrypt hashes must select bcrypt explicitly:
|
|
259
429
|
|
|
260
430
|
```ts
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
});
|
|
431
|
+
password: {
|
|
432
|
+
algorithm: "bcrypt",
|
|
433
|
+
}
|
|
265
434
|
```
|
|
266
435
|
|
|
267
|
-
|
|
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
|
-
|
|
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
|
-
|
|
444
|
+
### Session
|
|
272
445
|
|
|
273
446
|
```ts
|
|
274
447
|
session: {
|
|
275
|
-
|
|
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
|
-
|
|
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
|
-
|
|
283
|
-
|
|
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
|
-
|
|
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
|
-
|
|
647
|
+
## Performance and cache policy
|
|
290
648
|
|
|
291
|
-
|
|
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
|
-
|
|
651
|
+
Without cache, each `requireSession` performs:
|
|
294
652
|
|
|
295
653
|
```text
|
|
296
|
-
|
|
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.
|