@chidchanun/bcp 0.1.6 → 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 +61 -1
- package/README.md +185 -1
- package/docs/server-request-apis.md +404 -0
- package/docs/session-auth.md +186 -0
- package/package.json +6 -1
- package/packages/bundler/src/client-boundary.ts +8 -6
- package/packages/bundler/src/server-production.ts +17 -0
- package/packages/client/src/server.ts +36 -0
- package/packages/server/src/index.ts +32 -13
- package/packages/server/src/request-context.ts +1043 -0
- package/packages/server/src/server-response.ts +82 -0
- package/packages/server/src/session.ts +694 -0
- package/packages/server/src/standalone-production-runtime-v2.ts +44 -13
|
@@ -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",
|
|
@@ -43,6 +43,11 @@
|
|
|
43
43
|
"types": "./packages/client/src/config.ts",
|
|
44
44
|
"default": "./packages/client/src/config.ts"
|
|
45
45
|
},
|
|
46
|
+
"./server": {
|
|
47
|
+
"types": "./packages/client/src/server.ts",
|
|
48
|
+
"browser": "./packages/client/src/server-only.browser.mjs",
|
|
49
|
+
"default": "./packages/client/src/server.ts"
|
|
50
|
+
},
|
|
46
51
|
"./server-only": {
|
|
47
52
|
"types": "./packages/client/src/server-only.d.ts",
|
|
48
53
|
"browser": "./packages/client/src/server-only.browser.mjs",
|
|
@@ -22,6 +22,8 @@ const MODULE_EXTENSIONS = [
|
|
|
22
22
|
".cjs",
|
|
23
23
|
] as const;
|
|
24
24
|
|
|
25
|
+
const SERVER_ONLY_IMPORTS = new Set(["bcp/server","bcp/server-only"])
|
|
26
|
+
|
|
25
27
|
export function validateClientBoundaries(
|
|
26
28
|
routes: Route[],
|
|
27
29
|
rootDirectory: string
|
|
@@ -127,13 +129,13 @@ function visitClientModule(
|
|
|
127
129
|
source
|
|
128
130
|
)
|
|
129
131
|
) {
|
|
130
|
-
if (
|
|
131
|
-
specifier ===
|
|
132
|
-
"bcp/server-only"
|
|
133
|
-
) {
|
|
132
|
+
if (SERVER_ONLY_IMPORTS.has(specifier)){
|
|
134
133
|
throw new Error(
|
|
135
|
-
`BCP Framework: ${formatApplicationPath(
|
|
136
|
-
|
|
134
|
+
`BCP Framework: ${formatApplicationPath(
|
|
135
|
+
rootDirectory,
|
|
136
|
+
filePath
|
|
137
|
+
)} imports ${specifier} but is reachable from the client bundle for ${routeName}. Move the server dependency behind an API route.`
|
|
138
|
+
)
|
|
137
139
|
}
|
|
138
140
|
|
|
139
141
|
const dependency =
|
|
@@ -172,6 +172,12 @@ export async function buildProductionServer(
|
|
|
172
172
|
"../../client/src/islands.tsx"
|
|
173
173
|
);
|
|
174
174
|
|
|
175
|
+
const frameworkServerEntry =
|
|
176
|
+
path.resolve(
|
|
177
|
+
frameworkDirectory,
|
|
178
|
+
"../../client/src/server.ts"
|
|
179
|
+
);
|
|
180
|
+
|
|
175
181
|
await build({
|
|
176
182
|
absWorkingDir:
|
|
177
183
|
rootDirectory,
|
|
@@ -223,6 +229,17 @@ export async function buildProductionServer(
|
|
|
223
229
|
frameworkIslandEntry,
|
|
224
230
|
})
|
|
225
231
|
);
|
|
232
|
+
|
|
233
|
+
buildApi.onResolve(
|
|
234
|
+
{
|
|
235
|
+
filter:
|
|
236
|
+
/^bcp\/server$/,
|
|
237
|
+
},
|
|
238
|
+
() => ({
|
|
239
|
+
path:
|
|
240
|
+
frameworkServerEntry,
|
|
241
|
+
})
|
|
242
|
+
);
|
|
226
243
|
},
|
|
227
244
|
},
|
|
228
245
|
],
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
export {
|
|
2
|
+
bearerToken,
|
|
3
|
+
clientIp,
|
|
4
|
+
cookies,
|
|
5
|
+
headers,
|
|
6
|
+
requestId,
|
|
7
|
+
requestMethod,
|
|
8
|
+
requestUrl,
|
|
9
|
+
|
|
10
|
+
type ClientIpOptions,
|
|
11
|
+
type CookieSameSite,
|
|
12
|
+
type RequestCookie,
|
|
13
|
+
type RequestCookieStore,
|
|
14
|
+
type ResponseCookie,
|
|
15
|
+
type ResponseCookieOptions,
|
|
16
|
+
} from "../../server/src/request-context.js";
|
|
17
|
+
|
|
18
|
+
export {
|
|
19
|
+
json,
|
|
20
|
+
redirect,
|
|
21
|
+
|
|
22
|
+
type RedirectStatus,
|
|
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";
|
|
@@ -41,6 +41,8 @@ import {
|
|
|
41
41
|
DevHmrServer,
|
|
42
42
|
} from "./dev-hmr.js";
|
|
43
43
|
|
|
44
|
+
import { runWithRequestContext, applyResponseCookies } from "./request-context.ts";
|
|
45
|
+
|
|
44
46
|
/*
|
|
45
47
|
* =====================================
|
|
46
48
|
* Constants
|
|
@@ -1441,24 +1443,41 @@ async function handleApiRoute(
|
|
|
1441
1443
|
);
|
|
1442
1444
|
|
|
1443
1445
|
const response =
|
|
1444
|
-
await
|
|
1446
|
+
await runWithRequestContext(
|
|
1445
1447
|
request,
|
|
1448
|
+
|
|
1449
|
+
async () => {
|
|
1450
|
+
const result =
|
|
1451
|
+
await handler(
|
|
1452
|
+
request,
|
|
1453
|
+
{
|
|
1454
|
+
params,
|
|
1455
|
+
}
|
|
1456
|
+
);
|
|
1457
|
+
|
|
1458
|
+
if (
|
|
1459
|
+
!(
|
|
1460
|
+
result instanceof
|
|
1461
|
+
Response
|
|
1462
|
+
)
|
|
1463
|
+
) {
|
|
1464
|
+
throw new Error(
|
|
1465
|
+
`API handler "${method} ${route.pathname}" must return a Response object.`
|
|
1466
|
+
);
|
|
1467
|
+
}
|
|
1468
|
+
|
|
1469
|
+
return applyResponseCookies(
|
|
1470
|
+
result
|
|
1471
|
+
);
|
|
1472
|
+
},
|
|
1446
1473
|
{
|
|
1447
|
-
|
|
1474
|
+
remoteAddress:
|
|
1475
|
+
req.socket
|
|
1476
|
+
.remoteAddress ??
|
|
1477
|
+
null,
|
|
1448
1478
|
}
|
|
1449
1479
|
);
|
|
1450
1480
|
|
|
1451
|
-
if (
|
|
1452
|
-
!(
|
|
1453
|
-
response instanceof
|
|
1454
|
-
Response
|
|
1455
|
-
)
|
|
1456
|
-
) {
|
|
1457
|
-
throw new Error(
|
|
1458
|
-
`API handler "${method} ${route.pathname}" must return a Response object.`
|
|
1459
|
-
);
|
|
1460
|
-
}
|
|
1461
|
-
|
|
1462
1481
|
await sendWebResponse(
|
|
1463
1482
|
res,
|
|
1464
1483
|
response,
|