@chidchanun/bcp 0.2.5 → 0.2.6

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.
@@ -1,6 +1,6 @@
1
1
  # Auth Route Guards
2
2
 
3
- BCP Framework integrates Authentication Platform v2 with route guards through the server-only `bcp/auth` entrypoint.
3
+ BCP Framework integrates Authentication Platform v2 and Authorization & Security v2 with route guards through the server-only `bcp/auth` entrypoint.
4
4
 
5
5
  ## Protect a route tree
6
6
 
@@ -30,7 +30,7 @@ export const guard =
30
30
  });
31
31
  ```
32
32
 
33
- ## Guest-only routes — 0.2.5
33
+ ## Guest-only routes — 0.2.5+
34
34
 
35
35
  Login, registration, and password-recovery entry pages often should not remain accessible after the user has already authenticated.
36
36
 
@@ -60,30 +60,9 @@ signed-in user
60
60
  303 /dashboard
61
61
  ```
62
62
 
63
- Use `redirectTo: null` to return `409 Already authenticated` instead of redirecting:
63
+ Use `redirectTo: null` to return `409 Already authenticated` instead of redirecting.
64
64
 
65
- ```ts
66
- export const guard =
67
- createGuestGuard({
68
- redirectTo:
69
- null,
70
- });
71
- ```
72
-
73
- The low-level form is `requireGuest()`:
74
-
75
- ```ts
76
- import {
77
- requireGuest,
78
- } from "bcp/auth";
79
-
80
- export function guard() {
81
- return requireGuest({
82
- redirectTo:
83
- "/dashboard",
84
- });
85
- }
86
- ```
65
+ The low-level form is `requireGuest()`.
87
66
 
88
67
  ## Require a role
89
68
 
@@ -110,58 +89,87 @@ export const guard =
110
89
 
111
90
  An authenticated user without the required role receives `403 Forbidden` by default.
112
91
 
113
- You can redirect forbidden users instead:
92
+ Require one of several roles:
114
93
 
115
94
  ```ts
116
95
  export const guard =
117
- createRoleGuard<User>(
96
+ createRoleGuard<User>([
118
97
  "admin",
119
- {
120
- forbiddenRedirectTo:
121
- "/forbidden",
122
- }
123
- );
98
+ "manager",
99
+ ]);
124
100
  ```
125
101
 
126
- ## Require one of several roles
127
-
128
- The default role matching mode is `any`:
102
+ Require all values from a role field:
129
103
 
130
104
  ```ts
131
105
  export const guard =
132
- createRoleGuard<User>([
133
- "admin",
134
- "manager",
135
- ]);
106
+ createRoleGuard<User>(
107
+ [
108
+ "admin",
109
+ "billing",
110
+ ],
111
+ {
112
+ match: "all",
113
+ }
114
+ );
136
115
  ```
137
116
 
138
- A user with either role is allowed.
117
+ ## Require permissions 0.2.6+
139
118
 
140
- ## Require all permissions
141
-
142
- `requireRole()` and `createRoleGuard()` can read another user field and require every value:
119
+ Authorization & Security v2 adds a dedicated permission guard instead of requiring applications to overload role configuration.
143
120
 
144
121
  ```ts
122
+ import {
123
+ createPermissionGuard,
124
+ } from "bcp/auth";
125
+
145
126
  interface User {
146
127
  id: number;
147
128
  permissions: string[];
148
129
  }
149
130
 
150
131
  export const guard =
151
- createRoleGuard<User>(
132
+ createPermissionGuard<User>(
133
+ "users.read"
134
+ );
135
+ ```
136
+
137
+ The default permission field is `permissions`.
138
+
139
+ Require every permission:
140
+
141
+ ```ts
142
+ export const guard =
143
+ createPermissionGuard<User>(
152
144
  [
153
145
  "users.read",
154
146
  "users.write",
155
147
  ],
156
148
  {
157
- roleField:
158
- "permissions",
159
- match:
160
- "all",
149
+ match: "all",
150
+ }
151
+ );
152
+ ```
153
+
154
+ Use another user field such as `scopes`:
155
+
156
+ ```ts
157
+ export const guard =
158
+ createPermissionGuard<User>(
159
+ "billing.read",
160
+ {
161
+ permissionField:
162
+ "scopes",
161
163
  }
162
164
  );
163
165
  ```
164
166
 
167
+ The low-level form is `requirePermission()`.
168
+
169
+ Authenticated users without the required permission receive `403 Forbidden` by default. Use `forbiddenRedirectTo` when the application prefers a redirect.
170
+
171
+ For authorization decisions that depend on a resource or ownership rather than a flat permission, use the policy APIs documented in [Authorization & Security v2](authorization-security.md).
172
+
165
173
  ## Inline guard logic
166
174
 
167
175
  Use `requireAuth()` when a guard needs additional application-specific checks:
@@ -198,7 +206,7 @@ export async function guard() {
198
206
 
199
207
  ## Session-store policies apply inside guards
200
208
 
201
- `requireAuth()`, `requireRole()`, and `requireGuest()` all use the same `auth()` runtime.
209
+ `requireAuth()`, `requireRole()`, `requirePermission()`, and `requireGuest()` all use the same `auth()` runtime.
202
210
 
203
211
  When auth options include a server-side store and idle timeout, guard evaluation observes revocation and inactivity before allowing the route.
204
212
 
@@ -254,7 +262,7 @@ export async function loader({
254
262
 
255
263
  ## Standalone production behavior
256
264
 
257
- `bcp/auth` stays inside the production guard/runtime request-context graph. `requireAuth()`, `requireGuest()`, `requireRole()`, `auth()`, `getSession()`, and `cookies()` therefore observe the active request after `bcp build`.
265
+ `bcp/auth` stays inside the production guard/runtime request-context graph. `requireAuth()`, `requireGuest()`, `requireRole()`, `requirePermission()`, `auth()`, `getSession()`, and `cookies()` therefore observe the active request after `bcp build`.
258
266
 
259
267
  The production form-action bundle uses the same runtime boundary because guards may execute before an action.
260
268
 
@@ -0,0 +1,298 @@
1
+ # Authorization & Security v2
2
+
3
+ BCP Framework `0.2.6` adds permission- and policy-based authorization plus request-origin and CSRF protection primitives while keeping the existing authentication APIs backward compatible.
4
+
5
+ ## Permission checks
6
+
7
+ Permission helpers are exported from the server-only `bcp/auth` entrypoint.
8
+
9
+ ```ts
10
+ import {
11
+ hasPermission,
12
+ assertPermission,
13
+ } from "bcp/auth";
14
+
15
+ const user = {
16
+ id: 42,
17
+ permissions: [
18
+ "users.read",
19
+ "users.write",
20
+ ],
21
+ };
22
+
23
+ hasPermission(
24
+ user,
25
+ "users.read"
26
+ ); // true
27
+
28
+ hasPermission(
29
+ user,
30
+ [
31
+ "users.read",
32
+ "users.delete",
33
+ ],
34
+ {
35
+ match: "all",
36
+ }
37
+ ); // false
38
+ ```
39
+
40
+ The default permission field is `permissions`. Override it when an application uses another user shape:
41
+
42
+ ```ts
43
+ hasPermission(
44
+ user,
45
+ "billing.read",
46
+ {
47
+ field: "scopes",
48
+ }
49
+ );
50
+ ```
51
+
52
+ `assertPermission()` throws `AuthorizationError` with status `403` when the requirement is not satisfied.
53
+
54
+ ## Permission route guards
55
+
56
+ Protect a route tree with a permission requirement:
57
+
58
+ ```ts
59
+ import {
60
+ createPermissionGuard,
61
+ } from "bcp/auth";
62
+
63
+ export const guard =
64
+ createPermissionGuard(
65
+ "users.read"
66
+ );
67
+ ```
68
+
69
+ Require every permission:
70
+
71
+ ```ts
72
+ export const guard =
73
+ createPermissionGuard(
74
+ [
75
+ "users.read",
76
+ "users.write",
77
+ ],
78
+ {
79
+ match: "all",
80
+ }
81
+ );
82
+ ```
83
+
84
+ An unauthenticated request follows the normal `requireAuth()` behavior. An authenticated user without the required permission receives `403 Forbidden` by default.
85
+
86
+ A custom permission field and forbidden redirect are supported:
87
+
88
+ ```ts
89
+ export const guard =
90
+ createPermissionGuard(
91
+ "admin.access",
92
+ {
93
+ permissionField:
94
+ "scopes",
95
+ forbiddenRedirectTo:
96
+ "/forbidden",
97
+ }
98
+ );
99
+ ```
100
+
101
+ The lower-level `requirePermission()` helper is available for custom guard logic.
102
+
103
+ ## Authorization policies
104
+
105
+ Policies are useful when authorization depends on a resource, ownership, tenant, state, or other application-specific data instead of a flat permission string.
106
+
107
+ ```ts
108
+ import {
109
+ defineAuthorizationPolicy,
110
+ authorize,
111
+ can,
112
+ } from "bcp/auth";
113
+
114
+ interface User {
115
+ id: number;
116
+ }
117
+
118
+ interface Project {
119
+ ownerId: number;
120
+ }
121
+
122
+ const updateProject =
123
+ defineAuthorizationPolicy<
124
+ User,
125
+ Project
126
+ >(
127
+ ({
128
+ user,
129
+ resource,
130
+ }) =>
131
+ resource?.ownerId ===
132
+ user.id
133
+ );
134
+
135
+ const allowed =
136
+ await can(
137
+ updateProject,
138
+ {
139
+ user,
140
+ resource: project,
141
+ }
142
+ );
143
+
144
+ await authorize(
145
+ updateProject,
146
+ {
147
+ user,
148
+ resource: project,
149
+ }
150
+ );
151
+ ```
152
+
153
+ `cannot()` is the inverse of `can()`. `authorize()` throws `AuthorizationError` when the policy returns a falsy result.
154
+
155
+ Policies may be synchronous or asynchronous.
156
+
157
+ ## Same-origin mutation protection
158
+
159
+ Request-security helpers are exported from `bcp/server`.
160
+
161
+ ```ts
162
+ import {
163
+ requireSameOriginRequest,
164
+ } from "bcp/server";
165
+
166
+ export async function POST() {
167
+ await requireSameOriginRequest();
168
+
169
+ // mutation
170
+ }
171
+ ```
172
+
173
+ For unsafe HTTP methods BCP checks `Origin` first and then `Referer`. The application request origin is allowed automatically.
174
+
175
+ Additional trusted origins can be declared explicitly:
176
+
177
+ ```ts
178
+ await requireSameOriginRequest({
179
+ allowedOrigins: [
180
+ "https://admin.example.com",
181
+ ],
182
+ });
183
+ ```
184
+
185
+ Missing origin information is rejected for unsafe methods by default. Set `allowMissingOrigin: true` only when a trusted non-browser client cannot send either header and another protection is in place.
186
+
187
+ ## CSRF tokens
188
+
189
+ BCP provides an HttpOnly double-submit style CSRF cookie plus a signed token returned to server code.
190
+
191
+ Create a token during page rendering or another trusted same-origin response:
192
+
193
+ ```ts
194
+ import {
195
+ createCsrfToken,
196
+ } from "bcp/server";
197
+
198
+ const csrfToken =
199
+ await createCsrfToken();
200
+ ```
201
+
202
+ Pass the returned token to the page/form and submit it back in an application-controlled field or the default header:
203
+
204
+ ```text
205
+ X-BCP-CSRF: <token>
206
+ ```
207
+
208
+ Verify a mutation:
209
+
210
+ ```ts
211
+ import {
212
+ requireCsrfRequest,
213
+ } from "bcp/server";
214
+
215
+ export async function POST() {
216
+ await requireCsrfRequest();
217
+
218
+ // protected mutation
219
+ }
220
+ ```
221
+
222
+ When a form token is parsed from `FormData`, pass it explicitly:
223
+
224
+ ```ts
225
+ await requireCsrfRequest({
226
+ token:
227
+ String(
228
+ formData.get("csrf") ??
229
+ ""
230
+ ),
231
+ });
232
+ ```
233
+
234
+ `requireCsrfRequest()` combines same-origin validation and CSRF token validation for unsafe methods. Safe read requests do not require a token.
235
+
236
+ ## CSRF secret
237
+
238
+ The token signer resolves secrets in this order:
239
+
240
+ ```text
241
+ explicit options.secret
242
+ BCP_CSRF_SECRET
243
+ BCP_SESSION_SECRET
244
+ ```
245
+
246
+ Secrets must contain at least 32 UTF-8 bytes.
247
+
248
+ For deployments that want separate authentication and CSRF key rotation, define:
249
+
250
+ ```dotenv
251
+ BCP_SESSION_SECRET=...
252
+ BCP_CSRF_SECRET=...
253
+ ```
254
+
255
+ The default CSRF cookie is:
256
+
257
+ ```text
258
+ name bcp_csrf
259
+ HttpOnly true
260
+ SameSite Lax
261
+ Secure true in production
262
+ max age 2 hours
263
+ ```
264
+
265
+ The cookie is signed but the token itself must still be treated as security-sensitive request state. Do not log CSRF tokens.
266
+
267
+ ## Lower-level verification
268
+
269
+ The following helpers are available when applications need custom response behavior:
270
+
271
+ ```ts
272
+ isSafeHttpMethod()
273
+ isSameOriginRequest()
274
+ verifyCsrfToken()
275
+ verifyCsrfRequest()
276
+ destroyCsrfToken()
277
+ ```
278
+
279
+ `RequestSecurityError` uses status `403` and distinguishes `INVALID_ORIGIN` from `INVALID_CSRF_TOKEN`.
280
+
281
+ ## Security model
282
+
283
+ Authorization and CSRF protection solve different problems:
284
+
285
+ ```text
286
+ auth() / session
287
+ -> who is this user?
288
+
289
+ permission / policy
290
+ -> may this user perform this operation?
291
+
292
+ origin + CSRF
293
+ -> did this browser mutation come from an allowed application context?
294
+ ```
295
+
296
+ Applications should still validate all mutation input and enforce authorization on the server. Client-side UI checks are only presentation logic and must not replace server authorization.
297
+
298
+ `bcp/auth` and the request-security helpers in `bcp/server` are server-only surfaces.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "schemaVersion": 1,
3
3
  "framework": "bcp",
4
- "versionTarget": "0.2.5",
4
+ "versionTarget": "0.2.6",
5
5
  "releaseState": "unreleased",
6
6
  "sections": [
7
7
  {
@@ -35,12 +35,13 @@
35
35
  },
36
36
  {
37
37
  "id": "authentication",
38
- "title": "Authentication",
39
- "description": "Authentication Platform v2, revocable session stores, guest/auth route guards and JWT cookie sessions.",
38
+ "title": "Authentication & Authorization",
39
+ "description": "Authentication Platform v2, revocable sessions, permission/policy authorization, route guards and CSRF protection.",
40
40
  "pages": [
41
41
  { "route": "/docs/authentication", "source": "authentication.md", "title": "Authentication" },
42
42
  { "route": "/docs/auth-session-store", "source": "auth-session-store.md", "title": "Auth Session Stores" },
43
43
  { "route": "/docs/auth-route-guards", "source": "auth-route-guards.md", "title": "Auth Route Guards" },
44
+ { "route": "/docs/authorization-security", "source": "authorization-security.md", "title": "Authorization & Security v2" },
44
45
  { "route": "/docs/session-auth", "source": "session-auth.md", "title": "JWT Sessions" }
45
46
  ]
46
47
  },
@@ -106,7 +107,8 @@
106
107
  }
107
108
  ],
108
109
  "releases": [
109
- { "route": "/releases/0.2.5", "source": "releases/0.2.5.md", "version": "0.2.5", "state": "unreleased" },
110
+ { "route": "/releases/0.2.6", "source": "releases/0.2.6.md", "version": "0.2.6", "state": "unreleased" },
111
+ { "route": "/releases/0.2.5", "source": "releases/0.2.5.md", "version": "0.2.5" },
110
112
  { "route": "/releases/0.2.4", "source": "releases/0.2.4.md", "version": "0.2.4" },
111
113
  { "route": "/releases/0.2.3", "source": "releases/0.2.3.md", "version": "0.2.3" },
112
114
  { "route": "/releases/0.2.2", "source": "releases/0.2.2.md", "version": "0.2.2" },
@@ -1,9 +1,9 @@
1
1
  {
2
2
  "schemaVersion": 1,
3
3
  "framework": "bcp",
4
- "version": "0.2.5",
4
+ "version": "0.2.6",
5
5
  "releaseState": "unreleased",
6
- "baseline": "authentication-platform-v2",
6
+ "baseline": "authorization-security-v2",
7
7
  "runtime": {
8
8
  "node": ">=24.11.0",
9
9
  "react": "19",
@@ -53,6 +53,12 @@
53
53
  "authLogoutAll": true,
54
54
  "authIdleTimeout": true,
55
55
  "authGuestGuard": true,
56
+ "authorizationSecurityV2": true,
57
+ "permissionAuthorization": true,
58
+ "authorizationPolicies": true,
59
+ "permissionRouteGuards": true,
60
+ "sameOriginProtection": true,
61
+ "csrfProtection": true,
56
62
  "databaseMigrations": true,
57
63
  "databaseAdapterContract": true,
58
64
  "databasePostgresql": true,
@@ -92,7 +98,7 @@
92
98
  "s3-compatible"
93
99
  ],
94
100
  "compatibility": {
95
- "previousBaseline": "0.2.4",
101
+ "previousBaseline": "0.2.5",
96
102
  "intentionalBreakingChangesFromPreviousBaseline": false,
97
103
  "migrationGuide": "migration-0.2.md"
98
104
  },
@@ -107,7 +113,8 @@
107
113
  "applicationPackaging": "application-packaging.md",
108
114
  "authentication": "authentication.md",
109
115
  "authSessionStore": "auth-session-store.md",
116
+ "authorizationSecurity": "authorization-security.md",
110
117
  "migrationGuide": "migration-0.2.md",
111
- "releaseNotes": "releases/0.2.5.md"
118
+ "releaseNotes": "releases/0.2.6.md"
112
119
  }
113
120
  }