@chidchanun/bcp 0.1.7 → 0.1.8
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/CHANGELOG.md +30 -0
- package/README.md +109 -1
- package/docs/session-auth.md +186 -0
- package/package.json +1 -1
- package/packages/client/src/server.ts +13 -0
- package/packages/server/src/session.ts +694 -0
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,36 @@
|
|
|
2
2
|
|
|
3
3
|
All notable framework changes are tracked here before release.
|
|
4
4
|
|
|
5
|
+
## 0.1.8 - JWT cookie sessions and auth preset
|
|
6
|
+
|
|
7
|
+
### Authentication
|
|
8
|
+
|
|
9
|
+
- Added `createSessionToken()` and `verifySessionToken()` to `bcp/server` for HS256 JWT session tokens.
|
|
10
|
+
- Added `createSession()` to sign a token and store it in an HttpOnly cookie with secure production defaults.
|
|
11
|
+
- Added `getSession()` to read and verify the active session cookie and return typed claims or `null` for invalid, expired or tampered tokens.
|
|
12
|
+
- Added `destroySession()` to expire the configured session cookie.
|
|
13
|
+
- Session tokens use `BCP_SESSION_SECRET` by default and reject secrets shorter than 32 bytes.
|
|
14
|
+
- Token verification validates HS256 signatures with constant-time comparison plus expiration, optional issuer and optional audience checks.
|
|
15
|
+
- JWT cookie defaults are `bcp_session`, 12-hour lifetime, `HttpOnly`, `SameSite=Lax`, `Path=/`, and `Secure` when `NODE_ENV=production`.
|
|
16
|
+
- Added low-level and high-level session option types through the existing server-only `bcp/server` entrypoint.
|
|
17
|
+
|
|
18
|
+
### create-bcp-app
|
|
19
|
+
|
|
20
|
+
- Added interactive Authentication selection with `None` and `JWT Cookie` presets.
|
|
21
|
+
- Added the non-interactive `--auth <preset>` option with `none` and `jwt-cookie` values.
|
|
22
|
+
- JWT Cookie projects generate `lib/auth.ts` plus `/api/auth/login`, `/api/auth/logout` and `/api/auth/me` route handlers.
|
|
23
|
+
- Generated auth projects add `BCP_SESSION_SECRET=` to `.env.example`.
|
|
24
|
+
- Generated `authenticateCredentials(email, password)` returns `null` until the application connects its own database lookup and password-hash verification, keeping the starter secure by default.
|
|
25
|
+
- MySQL projects now generate a reusable `db` pool configured through `DB_HOST`, `DB_PORT`, `DB_USER`, `DB_PASSWORD` and `DB_NAME`, with development pool reuse through `globalThis`.
|
|
26
|
+
|
|
27
|
+
### Reliability
|
|
28
|
+
|
|
29
|
+
- Added unit coverage for token round-trips, signature tampering, wrong secrets, issuer/audience mismatches, expiration, missing/short secrets and cookie lifecycle behavior.
|
|
30
|
+
- Added development integration coverage for login-style session creation, authenticated reads and logout cookie expiration.
|
|
31
|
+
- Added standalone production E2E coverage for the same JWT cookie session lifecycle after production bundling.
|
|
32
|
+
- Added create-app regression coverage for JWT Cookie scaffolding, secure credential-verification defaults and authentication-disabled projects.
|
|
33
|
+
- Added a basic application session API fixture and package-smoke checks for the session runtime and public helper surface.
|
|
34
|
+
|
|
5
35
|
## 0.1.7 - Server request context and cookies
|
|
6
36
|
|
|
7
37
|
### Server APIs
|
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
BCP Framework is a React full-stack framework with file-based routing, SSR, client navigation, API routes, middleware, metadata, client islands, cache/revalidation, security defaults and standalone production builds.
|
|
4
4
|
|
|
5
|
-
> Current development version: `0.1.
|
|
5
|
+
> Current development version: `0.1.8`. BCP is still pre-1.0 and validates each release candidate before the manual npm publish step.
|
|
6
6
|
|
|
7
7
|
## Quick start
|
|
8
8
|
|
|
@@ -196,6 +196,113 @@ export async function POST() {
|
|
|
196
196
|
|
|
197
197
|
`redirect()` defaults to status `307`; supported statuses are `301`, `302`, `303`, `307` and `308`. Use `cookieStore.delete("session")` to expire a cookie. See [Server Request APIs](docs/server-request-apis.md) for proxy trust, URL handling, response helpers, request isolation, cookie options and boundary details.
|
|
198
198
|
|
|
199
|
+
## JWT cookie sessions
|
|
200
|
+
|
|
201
|
+
BCP 0.1.8 adds HS256 JWT cookie sessions directly to `bcp/server`. Configure a server-only secret of at least 32 bytes:
|
|
202
|
+
|
|
203
|
+
```env
|
|
204
|
+
BCP_SESSION_SECRET=replace-this-with-a-long-random-secret-at-least-32-bytes
|
|
205
|
+
```
|
|
206
|
+
|
|
207
|
+
Login-style APIs can create an HttpOnly session cookie without manually signing or serializing the JWT:
|
|
208
|
+
|
|
209
|
+
```ts
|
|
210
|
+
import {
|
|
211
|
+
createSession,
|
|
212
|
+
json,
|
|
213
|
+
} from "bcp/server";
|
|
214
|
+
|
|
215
|
+
export async function POST() {
|
|
216
|
+
await createSession(
|
|
217
|
+
{
|
|
218
|
+
userId: 42,
|
|
219
|
+
email: "user@example.com",
|
|
220
|
+
role: "admin",
|
|
221
|
+
},
|
|
222
|
+
{
|
|
223
|
+
issuer: "my-app",
|
|
224
|
+
audience: "my-app-users",
|
|
225
|
+
}
|
|
226
|
+
);
|
|
227
|
+
|
|
228
|
+
return json({
|
|
229
|
+
success: true,
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
```
|
|
233
|
+
|
|
234
|
+
Protected API routes can verify and read the session:
|
|
235
|
+
|
|
236
|
+
```ts
|
|
237
|
+
import {
|
|
238
|
+
getSession,
|
|
239
|
+
json,
|
|
240
|
+
} from "bcp/server";
|
|
241
|
+
|
|
242
|
+
export async function GET() {
|
|
243
|
+
const session =
|
|
244
|
+
await getSession<{
|
|
245
|
+
userId: number;
|
|
246
|
+
email: string;
|
|
247
|
+
role: string;
|
|
248
|
+
}>({
|
|
249
|
+
issuer: "my-app",
|
|
250
|
+
audience: "my-app-users",
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
if (!session) {
|
|
254
|
+
return json(
|
|
255
|
+
{
|
|
256
|
+
error: "Unauthorized",
|
|
257
|
+
},
|
|
258
|
+
{
|
|
259
|
+
status: 401,
|
|
260
|
+
}
|
|
261
|
+
);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
return json({
|
|
265
|
+
userId:
|
|
266
|
+
session.userId,
|
|
267
|
+
email:
|
|
268
|
+
session.email,
|
|
269
|
+
role:
|
|
270
|
+
session.role,
|
|
271
|
+
});
|
|
272
|
+
}
|
|
273
|
+
```
|
|
274
|
+
|
|
275
|
+
Use `destroySession()` to expire the cookie. Low-level `createSessionToken()` and `verifySessionToken()` helpers are also available when an application needs to manage token storage itself. JWT payloads are signed, not encrypted, so sensitive secrets must not be stored inside them. See [JWT Cookie Sessions](docs/session-auth.md) for options and the security model.
|
|
276
|
+
|
|
277
|
+
## create-bcp-app auth preset
|
|
278
|
+
|
|
279
|
+
BCP 0.1.8 can scaffold the JWT cookie foundation automatically:
|
|
280
|
+
|
|
281
|
+
```bash
|
|
282
|
+
npx create-bcp-app my-app --database mysql --auth jwt-cookie
|
|
283
|
+
```
|
|
284
|
+
|
|
285
|
+
Interactive setup offers `None` and `JWT Cookie`. Selecting JWT Cookie creates:
|
|
286
|
+
|
|
287
|
+
```text
|
|
288
|
+
lib/auth.ts
|
|
289
|
+
app/api/auth/login/route.ts
|
|
290
|
+
app/api/auth/logout/route.ts
|
|
291
|
+
app/api/auth/me/route.ts
|
|
292
|
+
```
|
|
293
|
+
|
|
294
|
+
and adds:
|
|
295
|
+
|
|
296
|
+
```env
|
|
297
|
+
BCP_SESSION_SECRET=
|
|
298
|
+
```
|
|
299
|
+
|
|
300
|
+
to `.env.example`.
|
|
301
|
+
|
|
302
|
+
The generated `authenticateCredentials(email, password)` returns `null` by default. Applications must connect it to their own database lookup and password-hash verification before login can succeed. This is intentional so a newly generated project does not trust user identity supplied directly by the browser.
|
|
303
|
+
|
|
304
|
+
The generated MySQL preset uses a reusable `db` pool and separate `DB_HOST`, `DB_PORT`, `DB_USER`, `DB_PASSWORD` and `DB_NAME` settings.
|
|
305
|
+
|
|
199
306
|
## Metadata
|
|
200
307
|
|
|
201
308
|
```ts
|
|
@@ -338,6 +445,7 @@ No real npm publish command is run automatically by the repository.
|
|
|
338
445
|
- [Getting Started](docs/getting-started.md)
|
|
339
446
|
- [Application Modules](docs/application-modules.md)
|
|
340
447
|
- [Server Request APIs](docs/server-request-apis.md)
|
|
448
|
+
- [JWT Cookie Sessions](docs/session-auth.md)
|
|
341
449
|
- [Routing](docs/routing.md)
|
|
342
450
|
- [Configuration](docs/configuration.md)
|
|
343
451
|
- [Caching](docs/caching.md)
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
# JWT Cookie Sessions
|
|
2
|
+
|
|
3
|
+
BCP Framework provides server-only JWT cookie session helpers through `bcp/server`.
|
|
4
|
+
|
|
5
|
+
The current session implementation uses HS256 with Node.js `crypto`, requires a secret of at least 32 bytes, stores the token in an HttpOnly cookie by default and validates expiry before returning a session.
|
|
6
|
+
|
|
7
|
+
## Environment
|
|
8
|
+
|
|
9
|
+
Set a strong session secret in the server environment:
|
|
10
|
+
|
|
11
|
+
```env
|
|
12
|
+
BCP_SESSION_SECRET=replace-this-with-a-long-random-secret-at-least-32-bytes
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
Do not expose this value to browser code or prefix it as a public environment variable.
|
|
16
|
+
|
|
17
|
+
## Create a session
|
|
18
|
+
|
|
19
|
+
`createSession()` signs a JWT and stores it in a cookie. The default cookie name is `bcp_session` and the default lifetime is 12 hours.
|
|
20
|
+
|
|
21
|
+
```ts
|
|
22
|
+
import {
|
|
23
|
+
createSession,
|
|
24
|
+
json,
|
|
25
|
+
} from "bcp/server";
|
|
26
|
+
|
|
27
|
+
export async function POST() {
|
|
28
|
+
await createSession(
|
|
29
|
+
{
|
|
30
|
+
userId: 42,
|
|
31
|
+
email: "user@example.com",
|
|
32
|
+
role: "admin",
|
|
33
|
+
},
|
|
34
|
+
{
|
|
35
|
+
issuer: "my-app",
|
|
36
|
+
audience: "my-app-users",
|
|
37
|
+
}
|
|
38
|
+
);
|
|
39
|
+
|
|
40
|
+
return json({
|
|
41
|
+
success: true,
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
By default BCP sets:
|
|
47
|
+
|
|
48
|
+
- `HttpOnly`
|
|
49
|
+
- `SameSite=Lax`
|
|
50
|
+
- `Path=/`
|
|
51
|
+
- `Secure` when `NODE_ENV=production`
|
|
52
|
+
- `Max-Age` equal to the token lifetime
|
|
53
|
+
|
|
54
|
+
Cookie and token behavior can be customized:
|
|
55
|
+
|
|
56
|
+
```ts
|
|
57
|
+
await createSession(
|
|
58
|
+
{
|
|
59
|
+
userId: 42,
|
|
60
|
+
},
|
|
61
|
+
{
|
|
62
|
+
cookieName: "access_token",
|
|
63
|
+
expiresIn: 60 * 60,
|
|
64
|
+
issuer: "my-app",
|
|
65
|
+
audience: [
|
|
66
|
+
"web",
|
|
67
|
+
"api",
|
|
68
|
+
],
|
|
69
|
+
sameSite: "strict",
|
|
70
|
+
secure: true,
|
|
71
|
+
path: "/",
|
|
72
|
+
}
|
|
73
|
+
);
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
## Read a session
|
|
77
|
+
|
|
78
|
+
`getSession()` reads the cookie and verifies the JWT signature, expiration, issuer and audience when those options are provided.
|
|
79
|
+
|
|
80
|
+
```ts
|
|
81
|
+
import {
|
|
82
|
+
getSession,
|
|
83
|
+
json,
|
|
84
|
+
} from "bcp/server";
|
|
85
|
+
|
|
86
|
+
export async function GET() {
|
|
87
|
+
const session =
|
|
88
|
+
await getSession<{
|
|
89
|
+
userId: number;
|
|
90
|
+
email: string;
|
|
91
|
+
role: string;
|
|
92
|
+
}>({
|
|
93
|
+
issuer: "my-app",
|
|
94
|
+
audience: "my-app-users",
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
if (!session) {
|
|
98
|
+
return json(
|
|
99
|
+
{
|
|
100
|
+
error: "Unauthorized",
|
|
101
|
+
},
|
|
102
|
+
{
|
|
103
|
+
status: 401,
|
|
104
|
+
}
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return json({
|
|
109
|
+
userId:
|
|
110
|
+
session.userId,
|
|
111
|
+
email:
|
|
112
|
+
session.email,
|
|
113
|
+
role:
|
|
114
|
+
session.role,
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
Invalid, expired or tampered session tokens return `null` instead of throwing.
|
|
120
|
+
|
|
121
|
+
## Destroy a session
|
|
122
|
+
|
|
123
|
+
`destroySession()` expires the session cookie:
|
|
124
|
+
|
|
125
|
+
```ts
|
|
126
|
+
import {
|
|
127
|
+
destroySession,
|
|
128
|
+
json,
|
|
129
|
+
} from "bcp/server";
|
|
130
|
+
|
|
131
|
+
export async function POST() {
|
|
132
|
+
await destroySession();
|
|
133
|
+
|
|
134
|
+
return json({
|
|
135
|
+
success: true,
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
When a custom cookie name, path or domain is used while creating the session, use the same values when destroying it.
|
|
141
|
+
|
|
142
|
+
## Low-level token APIs
|
|
143
|
+
|
|
144
|
+
BCP also exposes token-only helpers when an application needs to manage storage itself:
|
|
145
|
+
|
|
146
|
+
```ts
|
|
147
|
+
import {
|
|
148
|
+
createSessionToken,
|
|
149
|
+
verifySessionToken,
|
|
150
|
+
} from "bcp/server";
|
|
151
|
+
|
|
152
|
+
const token =
|
|
153
|
+
await createSessionToken(
|
|
154
|
+
{
|
|
155
|
+
userId: 42,
|
|
156
|
+
},
|
|
157
|
+
{
|
|
158
|
+
expiresIn: 3600,
|
|
159
|
+
issuer: "my-app",
|
|
160
|
+
}
|
|
161
|
+
);
|
|
162
|
+
|
|
163
|
+
const claims =
|
|
164
|
+
await verifySessionToken<{
|
|
165
|
+
userId: number;
|
|
166
|
+
}>(
|
|
167
|
+
token,
|
|
168
|
+
{
|
|
169
|
+
issuer: "my-app",
|
|
170
|
+
}
|
|
171
|
+
);
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
`createSessionToken()` and `verifySessionToken()` use `BCP_SESSION_SECRET` unless an explicit `secret` option is supplied.
|
|
175
|
+
|
|
176
|
+
## Security model
|
|
177
|
+
|
|
178
|
+
BCP session tokens use HS256 and reject secrets shorter than 32 bytes. Verification validates the signature with a constant-time comparison and rejects malformed, expired or unsupported JWTs.
|
|
179
|
+
|
|
180
|
+
JWT payloads are signed, not encrypted. Do not place passwords, API secrets or other confidential values in the session payload.
|
|
181
|
+
|
|
182
|
+
Session revocation is not automatic for stateless JWTs. Applications that require immediate revocation should store a session identifier in the token and validate it against a server-side session table or another revocation store.
|
|
183
|
+
|
|
184
|
+
## API route boundary
|
|
185
|
+
|
|
186
|
+
These helpers are exported from `bcp/server` and remain server-only. In the current 0.1.x page/client pipeline, call them from API routes rather than modules reachable from the browser bundle.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@chidchanun/bcp",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.8",
|
|
4
4
|
"description": "BCP Framework - a React full-stack framework with file-based routing, SSR, APIs, middleware, islands, caching and standalone production builds.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -21,3 +21,16 @@ export {
|
|
|
21
21
|
|
|
22
22
|
type RedirectStatus,
|
|
23
23
|
} from "../../server/src/server-response.js";
|
|
24
|
+
|
|
25
|
+
export {
|
|
26
|
+
createSession,
|
|
27
|
+
createSessionToken,
|
|
28
|
+
destroySession,
|
|
29
|
+
getSession,
|
|
30
|
+
verifySessionToken,
|
|
31
|
+
|
|
32
|
+
type SessionClaims,
|
|
33
|
+
type SessionCookieOptions,
|
|
34
|
+
type SessionPayload,
|
|
35
|
+
type SessionTokenOptions,
|
|
36
|
+
} from "../../server/src/session.js";
|
|
@@ -0,0 +1,694 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createHmac,
|
|
3
|
+
timingSafeEqual,
|
|
4
|
+
} from "node:crypto";
|
|
5
|
+
|
|
6
|
+
import {
|
|
7
|
+
cookies,
|
|
8
|
+
type CookieSameSite,
|
|
9
|
+
} from "./request-context.js";
|
|
10
|
+
|
|
11
|
+
const DEFAULT_COOKIE_NAME =
|
|
12
|
+
"bcp_session";
|
|
13
|
+
const DEFAULT_EXPIRES_IN =
|
|
14
|
+
60 * 60 * 12;
|
|
15
|
+
const MAX_TOKEN_LENGTH =
|
|
16
|
+
16 * 1024;
|
|
17
|
+
const MINIMUM_SECRET_BYTES =
|
|
18
|
+
32;
|
|
19
|
+
|
|
20
|
+
export interface SessionPayload {
|
|
21
|
+
[key: string]: unknown;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface SessionClaims
|
|
25
|
+
extends SessionPayload {
|
|
26
|
+
iat: number;
|
|
27
|
+
exp: number;
|
|
28
|
+
iss?: string;
|
|
29
|
+
aud?:
|
|
30
|
+
string |
|
|
31
|
+
string[];
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface SessionTokenOptions {
|
|
35
|
+
secret?: string;
|
|
36
|
+
expiresIn?: number;
|
|
37
|
+
issuer?: string;
|
|
38
|
+
audience?:
|
|
39
|
+
string |
|
|
40
|
+
string[];
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface SessionCookieOptions
|
|
44
|
+
extends SessionTokenOptions {
|
|
45
|
+
cookieName?: string;
|
|
46
|
+
path?: string;
|
|
47
|
+
domain?: string;
|
|
48
|
+
httpOnly?: boolean;
|
|
49
|
+
secure?: boolean;
|
|
50
|
+
sameSite?:
|
|
51
|
+
CookieSameSite;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export async function createSessionToken<
|
|
55
|
+
T extends object
|
|
56
|
+
>(
|
|
57
|
+
payload: T,
|
|
58
|
+
options:
|
|
59
|
+
SessionTokenOptions = {}
|
|
60
|
+
): Promise<string> {
|
|
61
|
+
assertPayload(
|
|
62
|
+
payload
|
|
63
|
+
);
|
|
64
|
+
|
|
65
|
+
const secret =
|
|
66
|
+
resolveSessionSecret(
|
|
67
|
+
options.secret
|
|
68
|
+
);
|
|
69
|
+
const expiresIn =
|
|
70
|
+
resolveExpiresIn(
|
|
71
|
+
options.expiresIn
|
|
72
|
+
);
|
|
73
|
+
const issuer =
|
|
74
|
+
resolveOptionalIssuer(
|
|
75
|
+
options.issuer
|
|
76
|
+
);
|
|
77
|
+
const audience =
|
|
78
|
+
options.audience ===
|
|
79
|
+
undefined
|
|
80
|
+
? undefined
|
|
81
|
+
: normalizeAudience(
|
|
82
|
+
options.audience
|
|
83
|
+
);
|
|
84
|
+
const now =
|
|
85
|
+
Math.floor(
|
|
86
|
+
Date.now() /
|
|
87
|
+
1000
|
|
88
|
+
);
|
|
89
|
+
|
|
90
|
+
const header = {
|
|
91
|
+
alg:
|
|
92
|
+
"HS256",
|
|
93
|
+
typ:
|
|
94
|
+
"JWT",
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
const claims:
|
|
98
|
+
SessionClaims = {
|
|
99
|
+
...payload,
|
|
100
|
+
iat:
|
|
101
|
+
now,
|
|
102
|
+
exp:
|
|
103
|
+
now +
|
|
104
|
+
expiresIn,
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
if (
|
|
108
|
+
issuer !==
|
|
109
|
+
undefined
|
|
110
|
+
) {
|
|
111
|
+
claims.iss =
|
|
112
|
+
issuer;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (
|
|
116
|
+
audience !==
|
|
117
|
+
undefined
|
|
118
|
+
) {
|
|
119
|
+
claims.aud =
|
|
120
|
+
audience;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const encodedHeader =
|
|
124
|
+
encodeJson(
|
|
125
|
+
header
|
|
126
|
+
);
|
|
127
|
+
const encodedPayload =
|
|
128
|
+
encodeJson(
|
|
129
|
+
claims
|
|
130
|
+
);
|
|
131
|
+
const signingInput =
|
|
132
|
+
`${encodedHeader}.${encodedPayload}`;
|
|
133
|
+
const signature =
|
|
134
|
+
signHs256(
|
|
135
|
+
signingInput,
|
|
136
|
+
secret
|
|
137
|
+
);
|
|
138
|
+
|
|
139
|
+
return `${signingInput}.${signature}`;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export async function verifySessionToken<
|
|
143
|
+
T extends object =
|
|
144
|
+
SessionPayload
|
|
145
|
+
>(
|
|
146
|
+
token: string,
|
|
147
|
+
options:
|
|
148
|
+
SessionTokenOptions = {}
|
|
149
|
+
): Promise<(
|
|
150
|
+
T & SessionClaims
|
|
151
|
+
) | null> {
|
|
152
|
+
const secret =
|
|
153
|
+
resolveSessionSecret(
|
|
154
|
+
options.secret
|
|
155
|
+
);
|
|
156
|
+
const issuer =
|
|
157
|
+
resolveOptionalIssuer(
|
|
158
|
+
options.issuer
|
|
159
|
+
);
|
|
160
|
+
const audience =
|
|
161
|
+
options.audience ===
|
|
162
|
+
undefined
|
|
163
|
+
? undefined
|
|
164
|
+
: normalizeAudience(
|
|
165
|
+
options.audience
|
|
166
|
+
);
|
|
167
|
+
|
|
168
|
+
try {
|
|
169
|
+
if (
|
|
170
|
+
typeof token !==
|
|
171
|
+
"string" ||
|
|
172
|
+
token.length === 0 ||
|
|
173
|
+
token.length >
|
|
174
|
+
MAX_TOKEN_LENGTH
|
|
175
|
+
) {
|
|
176
|
+
return null;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const parts =
|
|
180
|
+
token.split(".");
|
|
181
|
+
|
|
182
|
+
if (
|
|
183
|
+
parts.length !==
|
|
184
|
+
3
|
|
185
|
+
) {
|
|
186
|
+
return null;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const [
|
|
190
|
+
encodedHeader,
|
|
191
|
+
encodedPayload,
|
|
192
|
+
signature,
|
|
193
|
+
] = parts;
|
|
194
|
+
|
|
195
|
+
const header =
|
|
196
|
+
decodeJson(
|
|
197
|
+
encodedHeader
|
|
198
|
+
) as Record<
|
|
199
|
+
string,
|
|
200
|
+
unknown
|
|
201
|
+
>;
|
|
202
|
+
|
|
203
|
+
if (
|
|
204
|
+
header.alg !==
|
|
205
|
+
"HS256" ||
|
|
206
|
+
(
|
|
207
|
+
header.typ !==
|
|
208
|
+
undefined &&
|
|
209
|
+
header.typ !==
|
|
210
|
+
"JWT"
|
|
211
|
+
)
|
|
212
|
+
) {
|
|
213
|
+
return null;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
const signingInput =
|
|
217
|
+
`${encodedHeader}.${encodedPayload}`;
|
|
218
|
+
const expectedSignature =
|
|
219
|
+
signHs256(
|
|
220
|
+
signingInput,
|
|
221
|
+
secret
|
|
222
|
+
);
|
|
223
|
+
|
|
224
|
+
if (
|
|
225
|
+
!safeEqual(
|
|
226
|
+
signature,
|
|
227
|
+
expectedSignature
|
|
228
|
+
)
|
|
229
|
+
) {
|
|
230
|
+
return null;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
const claims =
|
|
234
|
+
decodeJson(
|
|
235
|
+
encodedPayload
|
|
236
|
+
) as Record<
|
|
237
|
+
string,
|
|
238
|
+
unknown
|
|
239
|
+
>;
|
|
240
|
+
const now =
|
|
241
|
+
Math.floor(
|
|
242
|
+
Date.now() /
|
|
243
|
+
1000
|
|
244
|
+
);
|
|
245
|
+
|
|
246
|
+
if (
|
|
247
|
+
typeof claims.iat !==
|
|
248
|
+
"number" ||
|
|
249
|
+
!Number.isFinite(
|
|
250
|
+
claims.iat
|
|
251
|
+
) ||
|
|
252
|
+
typeof claims.exp !==
|
|
253
|
+
"number" ||
|
|
254
|
+
!Number.isFinite(
|
|
255
|
+
claims.exp
|
|
256
|
+
) ||
|
|
257
|
+
claims.exp <=
|
|
258
|
+
now
|
|
259
|
+
) {
|
|
260
|
+
return null;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
if (
|
|
264
|
+
claims.nbf !==
|
|
265
|
+
undefined
|
|
266
|
+
) {
|
|
267
|
+
if (
|
|
268
|
+
typeof claims.nbf !==
|
|
269
|
+
"number" ||
|
|
270
|
+
!Number.isFinite(
|
|
271
|
+
claims.nbf
|
|
272
|
+
) ||
|
|
273
|
+
claims.nbf >
|
|
274
|
+
now
|
|
275
|
+
) {
|
|
276
|
+
return null;
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
if (
|
|
281
|
+
issuer !==
|
|
282
|
+
undefined &&
|
|
283
|
+
claims.iss !==
|
|
284
|
+
issuer
|
|
285
|
+
) {
|
|
286
|
+
return null;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
if (
|
|
290
|
+
audience !==
|
|
291
|
+
undefined &&
|
|
292
|
+
!audienceMatches(
|
|
293
|
+
claims.aud,
|
|
294
|
+
audience
|
|
295
|
+
)
|
|
296
|
+
) {
|
|
297
|
+
return null;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
return claims as
|
|
301
|
+
T & SessionClaims;
|
|
302
|
+
} catch {
|
|
303
|
+
return null;
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
export async function createSession<
|
|
308
|
+
T extends object
|
|
309
|
+
>(
|
|
310
|
+
payload: T,
|
|
311
|
+
options:
|
|
312
|
+
SessionCookieOptions = {}
|
|
313
|
+
): Promise<string> {
|
|
314
|
+
const expiresIn =
|
|
315
|
+
resolveExpiresIn(
|
|
316
|
+
options.expiresIn
|
|
317
|
+
);
|
|
318
|
+
const token =
|
|
319
|
+
await createSessionToken(
|
|
320
|
+
payload,
|
|
321
|
+
{
|
|
322
|
+
secret:
|
|
323
|
+
options.secret,
|
|
324
|
+
expiresIn,
|
|
325
|
+
issuer:
|
|
326
|
+
options.issuer,
|
|
327
|
+
audience:
|
|
328
|
+
options.audience,
|
|
329
|
+
}
|
|
330
|
+
);
|
|
331
|
+
const cookieStore =
|
|
332
|
+
await cookies();
|
|
333
|
+
|
|
334
|
+
cookieStore.set(
|
|
335
|
+
resolveCookieName(
|
|
336
|
+
options.cookieName
|
|
337
|
+
),
|
|
338
|
+
token,
|
|
339
|
+
{
|
|
340
|
+
httpOnly:
|
|
341
|
+
options.httpOnly ??
|
|
342
|
+
true,
|
|
343
|
+
secure:
|
|
344
|
+
options.secure ??
|
|
345
|
+
process.env.NODE_ENV ===
|
|
346
|
+
"production",
|
|
347
|
+
sameSite:
|
|
348
|
+
options.sameSite ??
|
|
349
|
+
"lax",
|
|
350
|
+
path:
|
|
351
|
+
options.path ??
|
|
352
|
+
"/",
|
|
353
|
+
domain:
|
|
354
|
+
options.domain,
|
|
355
|
+
maxAge:
|
|
356
|
+
expiresIn,
|
|
357
|
+
}
|
|
358
|
+
);
|
|
359
|
+
|
|
360
|
+
return token;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
export async function getSession<
|
|
364
|
+
T extends object =
|
|
365
|
+
SessionPayload
|
|
366
|
+
>(
|
|
367
|
+
options:
|
|
368
|
+
SessionCookieOptions = {}
|
|
369
|
+
): Promise<(
|
|
370
|
+
T & SessionClaims
|
|
371
|
+
) | null> {
|
|
372
|
+
const cookieStore =
|
|
373
|
+
await cookies();
|
|
374
|
+
const token =
|
|
375
|
+
cookieStore.get(
|
|
376
|
+
resolveCookieName(
|
|
377
|
+
options.cookieName
|
|
378
|
+
)
|
|
379
|
+
)?.value;
|
|
380
|
+
|
|
381
|
+
if (!token) {
|
|
382
|
+
return null;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
return verifySessionToken<
|
|
386
|
+
T
|
|
387
|
+
>(
|
|
388
|
+
token,
|
|
389
|
+
{
|
|
390
|
+
secret:
|
|
391
|
+
options.secret,
|
|
392
|
+
issuer:
|
|
393
|
+
options.issuer,
|
|
394
|
+
audience:
|
|
395
|
+
options.audience,
|
|
396
|
+
}
|
|
397
|
+
);
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
export async function destroySession(
|
|
401
|
+
options:
|
|
402
|
+
SessionCookieOptions = {}
|
|
403
|
+
): Promise<void> {
|
|
404
|
+
const cookieStore =
|
|
405
|
+
await cookies();
|
|
406
|
+
|
|
407
|
+
cookieStore.delete(
|
|
408
|
+
resolveCookieName(
|
|
409
|
+
options.cookieName
|
|
410
|
+
),
|
|
411
|
+
{
|
|
412
|
+
path:
|
|
413
|
+
options.path ??
|
|
414
|
+
"/",
|
|
415
|
+
domain:
|
|
416
|
+
options.domain,
|
|
417
|
+
}
|
|
418
|
+
);
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
function resolveSessionSecret(
|
|
422
|
+
explicitSecret:
|
|
423
|
+
string | undefined
|
|
424
|
+
): string {
|
|
425
|
+
const secret =
|
|
426
|
+
explicitSecret ??
|
|
427
|
+
process.env.BCP_SESSION_SECRET;
|
|
428
|
+
|
|
429
|
+
if (!secret) {
|
|
430
|
+
throw new Error(
|
|
431
|
+
"BCP Framework: BCP_SESSION_SECRET is required for session tokens."
|
|
432
|
+
);
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
if (
|
|
436
|
+
Buffer.byteLength(
|
|
437
|
+
secret,
|
|
438
|
+
"utf8"
|
|
439
|
+
) <
|
|
440
|
+
MINIMUM_SECRET_BYTES
|
|
441
|
+
) {
|
|
442
|
+
throw new Error(
|
|
443
|
+
`BCP Framework: session secret must be at least ${MINIMUM_SECRET_BYTES} bytes.`
|
|
444
|
+
);
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
return secret;
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
function resolveExpiresIn(
|
|
451
|
+
value:
|
|
452
|
+
number | undefined
|
|
453
|
+
): number {
|
|
454
|
+
const expiresIn =
|
|
455
|
+
value ??
|
|
456
|
+
DEFAULT_EXPIRES_IN;
|
|
457
|
+
|
|
458
|
+
if (
|
|
459
|
+
!Number.isFinite(
|
|
460
|
+
expiresIn
|
|
461
|
+
) ||
|
|
462
|
+
expiresIn <= 0
|
|
463
|
+
) {
|
|
464
|
+
throw new Error(
|
|
465
|
+
"BCP Framework: session expiresIn must be a positive finite number of seconds."
|
|
466
|
+
);
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
return Math.floor(
|
|
470
|
+
expiresIn
|
|
471
|
+
);
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
function resolveCookieName(
|
|
475
|
+
value:
|
|
476
|
+
string | undefined
|
|
477
|
+
): string {
|
|
478
|
+
const cookieName =
|
|
479
|
+
value ??
|
|
480
|
+
DEFAULT_COOKIE_NAME;
|
|
481
|
+
|
|
482
|
+
assertNonEmptyString(
|
|
483
|
+
cookieName,
|
|
484
|
+
"cookieName"
|
|
485
|
+
);
|
|
486
|
+
|
|
487
|
+
return cookieName;
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
function resolveOptionalIssuer(
|
|
491
|
+
value:
|
|
492
|
+
string | undefined
|
|
493
|
+
): string | undefined {
|
|
494
|
+
if (
|
|
495
|
+
value ===
|
|
496
|
+
undefined
|
|
497
|
+
) {
|
|
498
|
+
return undefined;
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
assertNonEmptyString(
|
|
502
|
+
value,
|
|
503
|
+
"issuer"
|
|
504
|
+
);
|
|
505
|
+
|
|
506
|
+
return value;
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
function assertPayload(
|
|
510
|
+
payload:
|
|
511
|
+
object
|
|
512
|
+
): void {
|
|
513
|
+
if (
|
|
514
|
+
payload === null ||
|
|
515
|
+
typeof payload !==
|
|
516
|
+
"object" ||
|
|
517
|
+
Array.isArray(
|
|
518
|
+
payload
|
|
519
|
+
)
|
|
520
|
+
) {
|
|
521
|
+
throw new Error(
|
|
522
|
+
"BCP Framework: session payload must be a plain object."
|
|
523
|
+
);
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
function assertNonEmptyString(
|
|
528
|
+
value: string,
|
|
529
|
+
label: string
|
|
530
|
+
): void {
|
|
531
|
+
if (
|
|
532
|
+
typeof value !==
|
|
533
|
+
"string" ||
|
|
534
|
+
value.trim().length ===
|
|
535
|
+
0
|
|
536
|
+
) {
|
|
537
|
+
throw new Error(
|
|
538
|
+
`BCP Framework: session ${label} must be a non-empty string.`
|
|
539
|
+
);
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
function normalizeAudience(
|
|
544
|
+
value:
|
|
545
|
+
string |
|
|
546
|
+
string[]
|
|
547
|
+
): string | string[] {
|
|
548
|
+
if (
|
|
549
|
+
typeof value ===
|
|
550
|
+
"string"
|
|
551
|
+
) {
|
|
552
|
+
assertNonEmptyString(
|
|
553
|
+
value,
|
|
554
|
+
"audience"
|
|
555
|
+
);
|
|
556
|
+
return value;
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
if (
|
|
560
|
+
!Array.isArray(
|
|
561
|
+
value
|
|
562
|
+
) ||
|
|
563
|
+
value.length ===
|
|
564
|
+
0
|
|
565
|
+
) {
|
|
566
|
+
throw new Error(
|
|
567
|
+
"BCP Framework: session audience must contain at least one value."
|
|
568
|
+
);
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
for (
|
|
572
|
+
const audience
|
|
573
|
+
of value
|
|
574
|
+
) {
|
|
575
|
+
assertNonEmptyString(
|
|
576
|
+
audience,
|
|
577
|
+
"audience"
|
|
578
|
+
);
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
return [
|
|
582
|
+
...value,
|
|
583
|
+
];
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
function audienceMatches(
|
|
587
|
+
claim: unknown,
|
|
588
|
+
expected:
|
|
589
|
+
string |
|
|
590
|
+
string[]
|
|
591
|
+
): boolean {
|
|
592
|
+
const expectedValues =
|
|
593
|
+
typeof expected ===
|
|
594
|
+
"string"
|
|
595
|
+
? [
|
|
596
|
+
expected,
|
|
597
|
+
]
|
|
598
|
+
: expected;
|
|
599
|
+
const claimValues =
|
|
600
|
+
typeof claim ===
|
|
601
|
+
"string"
|
|
602
|
+
? [
|
|
603
|
+
claim,
|
|
604
|
+
]
|
|
605
|
+
: Array.isArray(
|
|
606
|
+
claim
|
|
607
|
+
)
|
|
608
|
+
? claim.filter(
|
|
609
|
+
(
|
|
610
|
+
value
|
|
611
|
+
): value is string =>
|
|
612
|
+
typeof value ===
|
|
613
|
+
"string"
|
|
614
|
+
)
|
|
615
|
+
: [];
|
|
616
|
+
|
|
617
|
+
return expectedValues.some(
|
|
618
|
+
(value) =>
|
|
619
|
+
claimValues.includes(
|
|
620
|
+
value
|
|
621
|
+
)
|
|
622
|
+
);
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
function encodeJson(
|
|
626
|
+
value: unknown
|
|
627
|
+
): string {
|
|
628
|
+
return Buffer.from(
|
|
629
|
+
JSON.stringify(
|
|
630
|
+
value
|
|
631
|
+
),
|
|
632
|
+
"utf8"
|
|
633
|
+
).toString(
|
|
634
|
+
"base64url"
|
|
635
|
+
);
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
function decodeJson(
|
|
639
|
+
value: string
|
|
640
|
+
): unknown {
|
|
641
|
+
const decoded =
|
|
642
|
+
Buffer.from(
|
|
643
|
+
value,
|
|
644
|
+
"base64url"
|
|
645
|
+
).toString(
|
|
646
|
+
"utf8"
|
|
647
|
+
);
|
|
648
|
+
|
|
649
|
+
return JSON.parse(
|
|
650
|
+
decoded
|
|
651
|
+
);
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
function signHs256(
|
|
655
|
+
signingInput: string,
|
|
656
|
+
secret: string
|
|
657
|
+
): string {
|
|
658
|
+
return createHmac(
|
|
659
|
+
"sha256",
|
|
660
|
+
secret
|
|
661
|
+
)
|
|
662
|
+
.update(
|
|
663
|
+
signingInput,
|
|
664
|
+
"utf8"
|
|
665
|
+
)
|
|
666
|
+
.digest(
|
|
667
|
+
"base64url"
|
|
668
|
+
);
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
function safeEqual(
|
|
672
|
+
actual: string,
|
|
673
|
+
expected: string
|
|
674
|
+
): boolean {
|
|
675
|
+
const actualBuffer =
|
|
676
|
+
Buffer.from(
|
|
677
|
+
actual,
|
|
678
|
+
"base64url"
|
|
679
|
+
);
|
|
680
|
+
const expectedBuffer =
|
|
681
|
+
Buffer.from(
|
|
682
|
+
expected,
|
|
683
|
+
"base64url"
|
|
684
|
+
);
|
|
685
|
+
|
|
686
|
+
return (
|
|
687
|
+
actualBuffer.length ===
|
|
688
|
+
expectedBuffer.length &&
|
|
689
|
+
timingSafeEqual(
|
|
690
|
+
actualBuffer,
|
|
691
|
+
expectedBuffer
|
|
692
|
+
)
|
|
693
|
+
);
|
|
694
|
+
}
|