@chidchanun/bcp 0.1.15 → 0.1.17
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/docs/auth-route-guards.md +216 -0
- package/docs/authentication.md +201 -0
- package/docs/releases/0.1.16.md +53 -0
- package/docs/releases/0.1.17.md +36 -0
- package/package.json +6 -1
- package/packages/bundler/src/client-boundary.ts +13 -3
- package/packages/client/src/auth.ts +29 -0
- package/packages/server/src/auth-guard.ts +403 -0
- package/packages/server/src/auth.ts +376 -0
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
# Auth Route Guards
|
|
2
|
+
|
|
3
|
+
BCP Framework `0.1.17` integrates the Authentication Core with route guards through the server-only `bcp/auth` entrypoint.
|
|
4
|
+
|
|
5
|
+
## Protect a route tree
|
|
6
|
+
|
|
7
|
+
Create `guard.ts` in the route directory you want to protect:
|
|
8
|
+
|
|
9
|
+
```ts
|
|
10
|
+
import {
|
|
11
|
+
createAuthGuard,
|
|
12
|
+
} from "bcp/auth";
|
|
13
|
+
|
|
14
|
+
export const guard =
|
|
15
|
+
createAuthGuard({
|
|
16
|
+
redirectTo:
|
|
17
|
+
"/login",
|
|
18
|
+
});
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
The guard redirects unauthenticated requests to `/login` with status `303` by default. A `303` is suitable for guards that may run before form actions because it converts a redirected mutation request into a normal GET request to the login page.
|
|
22
|
+
|
|
23
|
+
To return `401 Unauthorized` instead of redirecting:
|
|
24
|
+
|
|
25
|
+
```ts
|
|
26
|
+
import {
|
|
27
|
+
createAuthGuard,
|
|
28
|
+
} from "bcp/auth";
|
|
29
|
+
|
|
30
|
+
export const guard =
|
|
31
|
+
createAuthGuard({
|
|
32
|
+
redirectTo:
|
|
33
|
+
null,
|
|
34
|
+
});
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
## Require a role
|
|
38
|
+
|
|
39
|
+
```ts
|
|
40
|
+
import {
|
|
41
|
+
createRoleGuard,
|
|
42
|
+
} from "bcp/auth";
|
|
43
|
+
|
|
44
|
+
interface User {
|
|
45
|
+
id: number;
|
|
46
|
+
email: string;
|
|
47
|
+
role: string;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export const guard =
|
|
51
|
+
createRoleGuard<User>(
|
|
52
|
+
"admin",
|
|
53
|
+
{
|
|
54
|
+
redirectTo:
|
|
55
|
+
"/login",
|
|
56
|
+
}
|
|
57
|
+
);
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
An authenticated user without the required role receives `403 Forbidden` by default.
|
|
61
|
+
|
|
62
|
+
You can redirect forbidden users instead:
|
|
63
|
+
|
|
64
|
+
```ts
|
|
65
|
+
export const guard =
|
|
66
|
+
createRoleGuard<User>(
|
|
67
|
+
"admin",
|
|
68
|
+
{
|
|
69
|
+
forbiddenRedirectTo:
|
|
70
|
+
"/forbidden",
|
|
71
|
+
}
|
|
72
|
+
);
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
## Require one of several roles
|
|
76
|
+
|
|
77
|
+
The default role matching mode is `any`:
|
|
78
|
+
|
|
79
|
+
```ts
|
|
80
|
+
export const guard =
|
|
81
|
+
createRoleGuard<User>([
|
|
82
|
+
"admin",
|
|
83
|
+
"manager",
|
|
84
|
+
]);
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
A user with either role is allowed.
|
|
88
|
+
|
|
89
|
+
## Require all permissions
|
|
90
|
+
|
|
91
|
+
`requireRole()` and `createRoleGuard()` can read another user field and require every value:
|
|
92
|
+
|
|
93
|
+
```ts
|
|
94
|
+
interface User {
|
|
95
|
+
id: number;
|
|
96
|
+
permissions: string[];
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export const guard =
|
|
100
|
+
createRoleGuard<User>(
|
|
101
|
+
[
|
|
102
|
+
"users.read",
|
|
103
|
+
"users.write",
|
|
104
|
+
],
|
|
105
|
+
{
|
|
106
|
+
roleField:
|
|
107
|
+
"permissions",
|
|
108
|
+
match:
|
|
109
|
+
"all",
|
|
110
|
+
}
|
|
111
|
+
);
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
## Inline guard logic
|
|
115
|
+
|
|
116
|
+
Use `requireAuth()` when a guard needs additional application-specific checks:
|
|
117
|
+
|
|
118
|
+
```ts
|
|
119
|
+
import {
|
|
120
|
+
requireAuth,
|
|
121
|
+
} from "bcp/auth";
|
|
122
|
+
|
|
123
|
+
export async function guard() {
|
|
124
|
+
const result =
|
|
125
|
+
await requireAuth<AppUser>();
|
|
126
|
+
|
|
127
|
+
if (
|
|
128
|
+
result instanceof
|
|
129
|
+
Response
|
|
130
|
+
) {
|
|
131
|
+
return result;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
if (
|
|
135
|
+
!result.auth.user.active
|
|
136
|
+
) {
|
|
137
|
+
return new Response(
|
|
138
|
+
"Forbidden",
|
|
139
|
+
{
|
|
140
|
+
status:
|
|
141
|
+
403,
|
|
142
|
+
}
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
return result;
|
|
147
|
+
}
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
## Auth data in child guards and loaders
|
|
151
|
+
|
|
152
|
+
Successful auth helpers return guard data in this shape:
|
|
153
|
+
|
|
154
|
+
```ts
|
|
155
|
+
{
|
|
156
|
+
auth: {
|
|
157
|
+
sid,
|
|
158
|
+
user,
|
|
159
|
+
data,
|
|
160
|
+
iat,
|
|
161
|
+
exp,
|
|
162
|
+
iss,
|
|
163
|
+
aud,
|
|
164
|
+
},
|
|
165
|
+
}
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
Because BCP merges parent guard data into child guards and page loaders, descendants can read the authenticated session without verifying the cookie again.
|
|
169
|
+
|
|
170
|
+
Use `getGuardAuth()` for typed access:
|
|
171
|
+
|
|
172
|
+
```ts
|
|
173
|
+
import {
|
|
174
|
+
getGuardAuth,
|
|
175
|
+
} from "bcp/auth";
|
|
176
|
+
|
|
177
|
+
export async function loader({
|
|
178
|
+
guardData,
|
|
179
|
+
}) {
|
|
180
|
+
const session =
|
|
181
|
+
getGuardAuth<AppUser>(
|
|
182
|
+
guardData
|
|
183
|
+
);
|
|
184
|
+
|
|
185
|
+
if (!session) {
|
|
186
|
+
throw new Error(
|
|
187
|
+
"Protected loader did not receive auth guard data."
|
|
188
|
+
);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
return {
|
|
192
|
+
userId:
|
|
193
|
+
session.user.id,
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
## Low-level role checks
|
|
199
|
+
|
|
200
|
+
`requireRole()` is also available directly:
|
|
201
|
+
|
|
202
|
+
```ts
|
|
203
|
+
import {
|
|
204
|
+
requireRole,
|
|
205
|
+
} from "bcp/auth";
|
|
206
|
+
|
|
207
|
+
export function guard() {
|
|
208
|
+
return requireRole<AppUser>(
|
|
209
|
+
"admin"
|
|
210
|
+
);
|
|
211
|
+
}
|
|
212
|
+
```
|
|
213
|
+
|
|
214
|
+
## Security boundary
|
|
215
|
+
|
|
216
|
+
`bcp/auth` is server-only. BCP blocks it from page/client graphs and maps its browser export to the server-only runtime guard. Authentication and authorization checks should stay in guards, loaders, actions, API routes, or other server modules.
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
# Authentication
|
|
2
|
+
|
|
3
|
+
BCP Framework 0.1.16 adds a server-only authentication layer through `bcp/auth`.
|
|
4
|
+
It builds on the signed JWT cookie/session primitives from `bcp/server` and provides a higher-level API for application authentication.
|
|
5
|
+
|
|
6
|
+
## Environment
|
|
7
|
+
|
|
8
|
+
Set a session secret with at least 32 bytes:
|
|
9
|
+
|
|
10
|
+
```env
|
|
11
|
+
BCP_SESSION_SECRET=replace-with-a-long-random-secret-at-least-32-bytes
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
The default cookie is `bcp_session`. It is HttpOnly, uses `SameSite=Lax`, has a 12-hour lifetime, and is Secure automatically when `NODE_ENV=production`.
|
|
15
|
+
|
|
16
|
+
## Basic usage
|
|
17
|
+
|
|
18
|
+
```ts
|
|
19
|
+
import {
|
|
20
|
+
auth,
|
|
21
|
+
login,
|
|
22
|
+
logout,
|
|
23
|
+
} from "bcp/auth";
|
|
24
|
+
|
|
25
|
+
export async function POST() {
|
|
26
|
+
await login({
|
|
27
|
+
id: 42,
|
|
28
|
+
email: "user@example.com",
|
|
29
|
+
role: "admin",
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
return Response.json({
|
|
33
|
+
ok: true,
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
Read the current authenticated session:
|
|
39
|
+
|
|
40
|
+
```ts
|
|
41
|
+
import {
|
|
42
|
+
auth,
|
|
43
|
+
} from "bcp/auth";
|
|
44
|
+
|
|
45
|
+
const session =
|
|
46
|
+
await auth<{
|
|
47
|
+
id: number;
|
|
48
|
+
email: string;
|
|
49
|
+
role: string;
|
|
50
|
+
}>();
|
|
51
|
+
|
|
52
|
+
if (!session) {
|
|
53
|
+
// Not authenticated.
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
console.log(
|
|
57
|
+
session?.user.id
|
|
58
|
+
);
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
`getSession()` is an alias for `auth()` when that naming is clearer in application code.
|
|
62
|
+
|
|
63
|
+
## Typed auth factory
|
|
64
|
+
|
|
65
|
+
For application-wide types and cookie settings, create a typed auth instance:
|
|
66
|
+
|
|
67
|
+
```ts
|
|
68
|
+
import {
|
|
69
|
+
createAuth,
|
|
70
|
+
} from "bcp/auth";
|
|
71
|
+
|
|
72
|
+
interface AppUser {
|
|
73
|
+
id: number;
|
|
74
|
+
email: string;
|
|
75
|
+
role: "admin" | "user";
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
interface AppSessionData {
|
|
79
|
+
tenantId: string;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export const appAuth =
|
|
83
|
+
createAuth<
|
|
84
|
+
AppUser,
|
|
85
|
+
AppSessionData
|
|
86
|
+
>({
|
|
87
|
+
cookieName:
|
|
88
|
+
"app_session",
|
|
89
|
+
expiresIn:
|
|
90
|
+
60 * 60 * 8,
|
|
91
|
+
issuer:
|
|
92
|
+
"my-app",
|
|
93
|
+
audience:
|
|
94
|
+
"web",
|
|
95
|
+
});
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
Login with typed session data:
|
|
99
|
+
|
|
100
|
+
```ts
|
|
101
|
+
await appAuth.login(
|
|
102
|
+
{
|
|
103
|
+
id: 42,
|
|
104
|
+
email: "user@example.com",
|
|
105
|
+
role: "admin",
|
|
106
|
+
},
|
|
107
|
+
{
|
|
108
|
+
data: {
|
|
109
|
+
tenantId:
|
|
110
|
+
"tenant-1",
|
|
111
|
+
},
|
|
112
|
+
}
|
|
113
|
+
);
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
Read it later:
|
|
117
|
+
|
|
118
|
+
```ts
|
|
119
|
+
const session =
|
|
120
|
+
await appAuth.auth();
|
|
121
|
+
|
|
122
|
+
console.log(
|
|
123
|
+
session?.user.email
|
|
124
|
+
);
|
|
125
|
+
console.log(
|
|
126
|
+
session?.data?.tenantId
|
|
127
|
+
);
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
## Logout
|
|
131
|
+
|
|
132
|
+
```ts
|
|
133
|
+
await logout();
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
Or with a factory:
|
|
137
|
+
|
|
138
|
+
```ts
|
|
139
|
+
await appAuth.logout();
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
Logout expires the configured authentication cookie.
|
|
143
|
+
|
|
144
|
+
## Session rotation
|
|
145
|
+
|
|
146
|
+
Each auth login receives a unique `sid` (session identifier). `rotateSession()` keeps the current user and session data but issues a new `sid`, JWT, expiry window, and cookie.
|
|
147
|
+
|
|
148
|
+
```ts
|
|
149
|
+
const rotated =
|
|
150
|
+
await appAuth.rotateSession();
|
|
151
|
+
|
|
152
|
+
if (rotated) {
|
|
153
|
+
console.log(
|
|
154
|
+
rotated.sid
|
|
155
|
+
);
|
|
156
|
+
}
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
Rotation returns `null` when no valid auth session exists.
|
|
160
|
+
|
|
161
|
+
A useful policy is to rotate after a security-sensitive event such as a privilege change or successful re-authentication.
|
|
162
|
+
|
|
163
|
+
## Session shape
|
|
164
|
+
|
|
165
|
+
An authenticated session contains the user, optional application session data, and signed JWT claims:
|
|
166
|
+
|
|
167
|
+
```ts
|
|
168
|
+
{
|
|
169
|
+
sid: string;
|
|
170
|
+
user: AppUser;
|
|
171
|
+
data?: AppSessionData;
|
|
172
|
+
iat: number;
|
|
173
|
+
exp: number;
|
|
174
|
+
iss?: string;
|
|
175
|
+
aud?: string | string[];
|
|
176
|
+
}
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
## Security notes
|
|
180
|
+
|
|
181
|
+
- `bcp/auth` is server-only and must not be imported into pages or client islands.
|
|
182
|
+
- Never put passwords, password hashes, API secrets, access keys, or other sensitive credentials in the auth user/session payload. JWT cookie payloads are signed, not encrypted.
|
|
183
|
+
- Keep `BCP_SESSION_SECRET` out of source control and use a strong random value of at least 32 bytes.
|
|
184
|
+
- Authentication verifies identity/session state. Application authorization such as roles and permissions belongs in route guards or server actions.
|
|
185
|
+
- Use HTTPS in production so Secure cookies are transmitted only over encrypted connections.
|
|
186
|
+
|
|
187
|
+
## create-bcp-app
|
|
188
|
+
|
|
189
|
+
When `JWT Cookie` authentication is selected, generated applications use `createAuth()` internally and expose helpers from `lib/auth.ts`:
|
|
190
|
+
|
|
191
|
+
```ts
|
|
192
|
+
import {
|
|
193
|
+
auth,
|
|
194
|
+
getSession,
|
|
195
|
+
login,
|
|
196
|
+
logout,
|
|
197
|
+
rotateSession,
|
|
198
|
+
} from "@/lib/auth";
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
The generated `authenticateCredentials()` intentionally returns `null` until the application implements its own user lookup and password verification strategy.
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# BCP Framework 0.1.16
|
|
2
|
+
|
|
3
|
+
## Authentication Core
|
|
4
|
+
|
|
5
|
+
BCP 0.1.16 introduces the server-only `bcp/auth` entrypoint.
|
|
6
|
+
|
|
7
|
+
### New APIs
|
|
8
|
+
|
|
9
|
+
- `auth()` reads and validates the current authentication session.
|
|
10
|
+
- `getSession()` is an auth-focused alias for `auth()`.
|
|
11
|
+
- `login(user, options)` creates an authenticated JWT cookie session.
|
|
12
|
+
- `logout(options)` expires the authentication cookie.
|
|
13
|
+
- `rotateSession(options)` preserves the authenticated user/session data while issuing a new session identifier and token.
|
|
14
|
+
- `createAuth<User, SessionData>(defaults)` creates a typed application auth instance with shared cookie/token configuration.
|
|
15
|
+
|
|
16
|
+
### Session identity
|
|
17
|
+
|
|
18
|
+
Every login now receives a random UUID `sid`. Session rotation always issues a new `sid`, so rotation produces a distinct signed token even if it happens within the same second.
|
|
19
|
+
|
|
20
|
+
### Security
|
|
21
|
+
|
|
22
|
+
- `bcp/auth` is exported as server-only in the package manifest.
|
|
23
|
+
- The client-boundary validator blocks `bcp/auth` from page/client graphs.
|
|
24
|
+
- The same validator now explicitly blocks direct `bcp/database` imports from client graphs as well.
|
|
25
|
+
- Existing JWT signing, expiration, issuer/audience validation, HttpOnly cookies, production Secure defaults, and minimum 32-byte session secrets remain provided by the underlying session runtime.
|
|
26
|
+
|
|
27
|
+
### create-bcp-app
|
|
28
|
+
|
|
29
|
+
The JWT Cookie preset now uses the framework auth core through `createAuth()` while keeping the existing starter helper names and API routes compatible.
|
|
30
|
+
|
|
31
|
+
Generated `lib/auth.ts` also exposes:
|
|
32
|
+
|
|
33
|
+
```ts
|
|
34
|
+
export const auth = frameworkAuth.auth;
|
|
35
|
+
export const getSession = frameworkAuth.getSession;
|
|
36
|
+
export const login = frameworkAuth.login;
|
|
37
|
+
export const logout = frameworkAuth.logout;
|
|
38
|
+
export const rotateSession = frameworkAuth.rotateSession;
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
`authenticateCredentials()` still returns `null` until the application implements its own user lookup and password verification.
|
|
42
|
+
|
|
43
|
+
### Tests
|
|
44
|
+
|
|
45
|
+
0.1.16 adds regression coverage for:
|
|
46
|
+
|
|
47
|
+
- typed login/read/logout lifecycle;
|
|
48
|
+
- typed `createAuth()` defaults;
|
|
49
|
+
- session rotation and changing `sid` values;
|
|
50
|
+
- invalid auth payload rejection;
|
|
51
|
+
- published `bcp/auth` export wiring;
|
|
52
|
+
- client-boundary blocking for auth/database;
|
|
53
|
+
- JWT Cookie scaffold integration with the framework auth core.
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# BCP Framework 0.1.17
|
|
2
|
+
|
|
3
|
+
## Auth + Route Guard Integration
|
|
4
|
+
|
|
5
|
+
BCP Framework 0.1.17 connects the Authentication Core introduced in 0.1.16 with the existing scoped route guard system.
|
|
6
|
+
|
|
7
|
+
### New `bcp/auth` guard helpers
|
|
8
|
+
|
|
9
|
+
- `requireAuth()` verifies the active BCP auth session and returns it as guard data.
|
|
10
|
+
- `requireRole()` verifies authentication plus one or more roles/permissions.
|
|
11
|
+
- `createAuthGuard()` creates a guard function that can be exported directly from `guard.ts`.
|
|
12
|
+
- `createRoleGuard()` creates a role-protected guard function.
|
|
13
|
+
- `getGuardAuth()` safely reads typed auth data from merged `guardData`.
|
|
14
|
+
|
|
15
|
+
Successful auth helpers return the session under `guardData.auth`, so child guards and page loaders can reuse the authenticated user without verifying the cookie again.
|
|
16
|
+
|
|
17
|
+
### Unauthorized and forbidden behavior
|
|
18
|
+
|
|
19
|
+
- Unauthenticated requests redirect to `/login` with HTTP `303` by default.
|
|
20
|
+
- `redirectTo: null` returns `401 Unauthorized` instead.
|
|
21
|
+
- Authenticated users that fail a role requirement receive `403 Forbidden` by default.
|
|
22
|
+
- `forbiddenRedirectTo` can redirect insufficient-role users to a custom page.
|
|
23
|
+
|
|
24
|
+
### Role matching
|
|
25
|
+
|
|
26
|
+
- A single role can be required with `requireRole("admin")`.
|
|
27
|
+
- Multiple roles default to `match: "any"`.
|
|
28
|
+
- `match: "all"` requires every requested value.
|
|
29
|
+
- `roleField` allows permission arrays or custom role fields such as `permissions` instead of the default `role` field.
|
|
30
|
+
|
|
31
|
+
### Reliability
|
|
32
|
+
|
|
33
|
+
- Added unit coverage for authentication redirects, 401 mode, role allow/deny behavior, custom role fields and guard factories.
|
|
34
|
+
- Added page-guard pipeline integration coverage proving auth data flows from a parent auth guard into child guards.
|
|
35
|
+
- Added publish-surface regression coverage for the route guard helpers exposed through `bcp/auth`.
|
|
36
|
+
- Added dedicated Auth Route Guards documentation.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@chidchanun/bcp",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.17",
|
|
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",
|
|
@@ -48,6 +48,11 @@
|
|
|
48
48
|
"browser": "./packages/client/src/server-only.browser.mjs",
|
|
49
49
|
"default": "./packages/client/src/database.ts"
|
|
50
50
|
},
|
|
51
|
+
"./auth": {
|
|
52
|
+
"types": "./packages/client/src/auth.ts",
|
|
53
|
+
"browser": "./packages/client/src/server-only.browser.mjs",
|
|
54
|
+
"default": "./packages/client/src/auth.ts"
|
|
55
|
+
},
|
|
51
56
|
"./server": {
|
|
52
57
|
"types": "./packages/client/src/server.ts",
|
|
53
58
|
"browser": "./packages/client/src/server-only.browser.mjs",
|
|
@@ -22,7 +22,13 @@ const MODULE_EXTENSIONS = [
|
|
|
22
22
|
".cjs",
|
|
23
23
|
] as const;
|
|
24
24
|
|
|
25
|
-
const SERVER_ONLY_IMPORTS =
|
|
25
|
+
const SERVER_ONLY_IMPORTS =
|
|
26
|
+
new Set([
|
|
27
|
+
"bcp/server",
|
|
28
|
+
"bcp/server-only",
|
|
29
|
+
"bcp/database",
|
|
30
|
+
"bcp/auth",
|
|
31
|
+
]);
|
|
26
32
|
|
|
27
33
|
export function validateClientBoundaries(
|
|
28
34
|
routes: Route[],
|
|
@@ -129,13 +135,17 @@ function visitClientModule(
|
|
|
129
135
|
source
|
|
130
136
|
)
|
|
131
137
|
) {
|
|
132
|
-
if (
|
|
138
|
+
if (
|
|
139
|
+
SERVER_ONLY_IMPORTS.has(
|
|
140
|
+
specifier
|
|
141
|
+
)
|
|
142
|
+
) {
|
|
133
143
|
throw new Error(
|
|
134
144
|
`BCP Framework: ${formatApplicationPath(
|
|
135
145
|
rootDirectory,
|
|
136
146
|
filePath
|
|
137
147
|
)} imports ${specifier} but is reachable from the client bundle for ${routeName}. Move the server dependency behind an API route.`
|
|
138
|
-
)
|
|
148
|
+
);
|
|
139
149
|
}
|
|
140
150
|
|
|
141
151
|
const dependency =
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
export {
|
|
2
|
+
auth,
|
|
3
|
+
createAuth,
|
|
4
|
+
getSession,
|
|
5
|
+
login,
|
|
6
|
+
logout,
|
|
7
|
+
rotateSession,
|
|
8
|
+
|
|
9
|
+
type AuthApi,
|
|
10
|
+
type AuthLoginOptions,
|
|
11
|
+
type AuthOptions,
|
|
12
|
+
type AuthSession,
|
|
13
|
+
type AuthUser,
|
|
14
|
+
} from "../../server/src/auth.js";
|
|
15
|
+
|
|
16
|
+
export {
|
|
17
|
+
createAuthGuard,
|
|
18
|
+
createRoleGuard,
|
|
19
|
+
getGuardAuth,
|
|
20
|
+
requireAuth,
|
|
21
|
+
requireRole,
|
|
22
|
+
|
|
23
|
+
type AuthGuardData,
|
|
24
|
+
type AuthGuardFunction,
|
|
25
|
+
type AuthGuardResult,
|
|
26
|
+
type RequireAuthOptions,
|
|
27
|
+
type RequireRoleOptions,
|
|
28
|
+
type RequiredRole,
|
|
29
|
+
} from "../../server/src/auth-guard.js";
|
|
@@ -0,0 +1,403 @@
|
|
|
1
|
+
import {
|
|
2
|
+
auth,
|
|
3
|
+
type AuthOptions,
|
|
4
|
+
type AuthSession,
|
|
5
|
+
type AuthUser,
|
|
6
|
+
} from "./auth.js";
|
|
7
|
+
import {
|
|
8
|
+
redirect,
|
|
9
|
+
type RedirectStatus,
|
|
10
|
+
} from "./server-response.js";
|
|
11
|
+
|
|
12
|
+
export interface AuthGuardData<
|
|
13
|
+
TUser extends AuthUser = AuthUser,
|
|
14
|
+
TData extends object = Record<string, never>
|
|
15
|
+
> extends Record<string, unknown> {
|
|
16
|
+
auth: AuthSession<TUser, TData>;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface RequireAuthOptions
|
|
20
|
+
extends AuthOptions {
|
|
21
|
+
redirectTo?:
|
|
22
|
+
string |
|
|
23
|
+
URL |
|
|
24
|
+
null;
|
|
25
|
+
redirectStatus?: RedirectStatus;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface RequireRoleOptions
|
|
29
|
+
extends RequireAuthOptions {
|
|
30
|
+
roleField?: string;
|
|
31
|
+
match?: "any" | "all";
|
|
32
|
+
forbiddenRedirectTo?:
|
|
33
|
+
string |
|
|
34
|
+
URL |
|
|
35
|
+
null;
|
|
36
|
+
forbiddenRedirectStatus?: RedirectStatus;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export type RequiredRole =
|
|
40
|
+
string |
|
|
41
|
+
readonly string[];
|
|
42
|
+
|
|
43
|
+
export type AuthGuardResult<
|
|
44
|
+
TUser extends AuthUser = AuthUser,
|
|
45
|
+
TData extends object = Record<string, never>
|
|
46
|
+
> =
|
|
47
|
+
| AuthGuardData<TUser, TData>
|
|
48
|
+
| Response;
|
|
49
|
+
|
|
50
|
+
export type AuthGuardFunction<
|
|
51
|
+
TUser extends AuthUser = AuthUser,
|
|
52
|
+
TData extends object = Record<string, never>
|
|
53
|
+
> = () => Promise<
|
|
54
|
+
AuthGuardResult<
|
|
55
|
+
TUser,
|
|
56
|
+
TData
|
|
57
|
+
>
|
|
58
|
+
>;
|
|
59
|
+
|
|
60
|
+
export async function requireAuth<
|
|
61
|
+
TUser extends AuthUser = AuthUser,
|
|
62
|
+
TData extends object = Record<string, never>
|
|
63
|
+
>(
|
|
64
|
+
options: RequireAuthOptions = {}
|
|
65
|
+
): Promise<AuthGuardResult<TUser, TData>> {
|
|
66
|
+
const {
|
|
67
|
+
redirectTo = "/login",
|
|
68
|
+
redirectStatus = 303,
|
|
69
|
+
...authOptions
|
|
70
|
+
} = options;
|
|
71
|
+
const session =
|
|
72
|
+
await auth<
|
|
73
|
+
TUser,
|
|
74
|
+
TData
|
|
75
|
+
>(
|
|
76
|
+
authOptions
|
|
77
|
+
);
|
|
78
|
+
|
|
79
|
+
if (!session) {
|
|
80
|
+
if (
|
|
81
|
+
redirectTo ===
|
|
82
|
+
null
|
|
83
|
+
) {
|
|
84
|
+
return new Response(
|
|
85
|
+
"Unauthorized",
|
|
86
|
+
{
|
|
87
|
+
status:
|
|
88
|
+
401,
|
|
89
|
+
}
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return redirect(
|
|
94
|
+
redirectTo,
|
|
95
|
+
redirectStatus
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return {
|
|
100
|
+
auth:
|
|
101
|
+
session,
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export async function requireRole<
|
|
106
|
+
TUser extends AuthUser = AuthUser,
|
|
107
|
+
TData extends object = Record<string, never>
|
|
108
|
+
>(
|
|
109
|
+
requiredRole: RequiredRole,
|
|
110
|
+
options: RequireRoleOptions = {}
|
|
111
|
+
): Promise<AuthGuardResult<TUser, TData>> {
|
|
112
|
+
const roles =
|
|
113
|
+
normalizeRequiredRoles(
|
|
114
|
+
requiredRole
|
|
115
|
+
);
|
|
116
|
+
const {
|
|
117
|
+
roleField = "role",
|
|
118
|
+
match = "any",
|
|
119
|
+
forbiddenRedirectTo = null,
|
|
120
|
+
forbiddenRedirectStatus = 303,
|
|
121
|
+
...authOptions
|
|
122
|
+
} = options;
|
|
123
|
+
const authenticated =
|
|
124
|
+
await requireAuth<
|
|
125
|
+
TUser,
|
|
126
|
+
TData
|
|
127
|
+
>(
|
|
128
|
+
authOptions
|
|
129
|
+
);
|
|
130
|
+
|
|
131
|
+
if (
|
|
132
|
+
authenticated instanceof
|
|
133
|
+
Response
|
|
134
|
+
) {
|
|
135
|
+
return authenticated;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const assignedRoles =
|
|
139
|
+
readAssignedRoles(
|
|
140
|
+
authenticated.auth.user,
|
|
141
|
+
roleField
|
|
142
|
+
);
|
|
143
|
+
const allowed =
|
|
144
|
+
match === "all"
|
|
145
|
+
? roles.every(
|
|
146
|
+
(role) =>
|
|
147
|
+
assignedRoles.includes(
|
|
148
|
+
role
|
|
149
|
+
)
|
|
150
|
+
)
|
|
151
|
+
: roles.some(
|
|
152
|
+
(role) =>
|
|
153
|
+
assignedRoles.includes(
|
|
154
|
+
role
|
|
155
|
+
)
|
|
156
|
+
);
|
|
157
|
+
|
|
158
|
+
if (!allowed) {
|
|
159
|
+
if (
|
|
160
|
+
forbiddenRedirectTo !==
|
|
161
|
+
null
|
|
162
|
+
) {
|
|
163
|
+
return redirect(
|
|
164
|
+
forbiddenRedirectTo,
|
|
165
|
+
forbiddenRedirectStatus
|
|
166
|
+
);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
return new Response(
|
|
170
|
+
"Forbidden",
|
|
171
|
+
{
|
|
172
|
+
status:
|
|
173
|
+
403,
|
|
174
|
+
}
|
|
175
|
+
);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
return authenticated;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export function createAuthGuard<
|
|
182
|
+
TUser extends AuthUser = AuthUser,
|
|
183
|
+
TData extends object = Record<string, never>
|
|
184
|
+
>(
|
|
185
|
+
options: RequireAuthOptions = {}
|
|
186
|
+
): AuthGuardFunction<TUser, TData> {
|
|
187
|
+
return () =>
|
|
188
|
+
requireAuth<
|
|
189
|
+
TUser,
|
|
190
|
+
TData
|
|
191
|
+
>(
|
|
192
|
+
options
|
|
193
|
+
);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
export function createRoleGuard<
|
|
197
|
+
TUser extends AuthUser = AuthUser,
|
|
198
|
+
TData extends object = Record<string, never>
|
|
199
|
+
>(
|
|
200
|
+
requiredRole: RequiredRole,
|
|
201
|
+
options: RequireRoleOptions = {}
|
|
202
|
+
): AuthGuardFunction<TUser, TData> {
|
|
203
|
+
return () =>
|
|
204
|
+
requireRole<
|
|
205
|
+
TUser,
|
|
206
|
+
TData
|
|
207
|
+
>(
|
|
208
|
+
requiredRole,
|
|
209
|
+
options
|
|
210
|
+
);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
export function getGuardAuth<
|
|
214
|
+
TUser extends AuthUser = AuthUser,
|
|
215
|
+
TData extends object = Record<string, never>
|
|
216
|
+
>(
|
|
217
|
+
guardData:
|
|
218
|
+
Readonly<{
|
|
219
|
+
auth?: unknown;
|
|
220
|
+
}>
|
|
221
|
+
): AuthSession<TUser, TData> | null {
|
|
222
|
+
const value =
|
|
223
|
+
guardData.auth;
|
|
224
|
+
|
|
225
|
+
if (
|
|
226
|
+
!value ||
|
|
227
|
+
typeof value !==
|
|
228
|
+
"object" ||
|
|
229
|
+
Array.isArray(
|
|
230
|
+
value
|
|
231
|
+
)
|
|
232
|
+
) {
|
|
233
|
+
return null;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
const session =
|
|
237
|
+
value as Partial<
|
|
238
|
+
AuthSession<
|
|
239
|
+
TUser,
|
|
240
|
+
TData
|
|
241
|
+
>
|
|
242
|
+
>;
|
|
243
|
+
|
|
244
|
+
if (
|
|
245
|
+
typeof session.sid !==
|
|
246
|
+
"string" ||
|
|
247
|
+
session.sid.trim().length ===
|
|
248
|
+
0 ||
|
|
249
|
+
typeof session.iat !==
|
|
250
|
+
"number" ||
|
|
251
|
+
!Number.isFinite(
|
|
252
|
+
session.iat
|
|
253
|
+
) ||
|
|
254
|
+
typeof session.exp !==
|
|
255
|
+
"number" ||
|
|
256
|
+
!Number.isFinite(
|
|
257
|
+
session.exp
|
|
258
|
+
) ||
|
|
259
|
+
!isGuardAuthUser(
|
|
260
|
+
session.user
|
|
261
|
+
)
|
|
262
|
+
) {
|
|
263
|
+
return null;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
return value as
|
|
267
|
+
AuthSession<
|
|
268
|
+
TUser,
|
|
269
|
+
TData
|
|
270
|
+
>;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function normalizeRequiredRoles(
|
|
274
|
+
value: RequiredRole
|
|
275
|
+
): string[] {
|
|
276
|
+
const roles =
|
|
277
|
+
typeof value ===
|
|
278
|
+
"string"
|
|
279
|
+
? [
|
|
280
|
+
value,
|
|
281
|
+
]
|
|
282
|
+
: [
|
|
283
|
+
...value,
|
|
284
|
+
];
|
|
285
|
+
const normalized =
|
|
286
|
+
roles.map(
|
|
287
|
+
(role) =>
|
|
288
|
+
role.trim()
|
|
289
|
+
);
|
|
290
|
+
|
|
291
|
+
if (
|
|
292
|
+
normalized.length ===
|
|
293
|
+
0 ||
|
|
294
|
+
normalized.some(
|
|
295
|
+
(role) =>
|
|
296
|
+
role.length ===
|
|
297
|
+
0
|
|
298
|
+
)
|
|
299
|
+
) {
|
|
300
|
+
throw new TypeError(
|
|
301
|
+
"BCP Auth Guard: required roles must contain non-empty strings."
|
|
302
|
+
);
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
return Array.from(
|
|
306
|
+
new Set(
|
|
307
|
+
normalized
|
|
308
|
+
)
|
|
309
|
+
);
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
function readAssignedRoles(
|
|
313
|
+
user: AuthUser,
|
|
314
|
+
roleField: string
|
|
315
|
+
): string[] {
|
|
316
|
+
const field =
|
|
317
|
+
roleField.trim();
|
|
318
|
+
|
|
319
|
+
if (!field) {
|
|
320
|
+
throw new TypeError(
|
|
321
|
+
"BCP Auth Guard: roleField must be a non-empty string."
|
|
322
|
+
);
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
const value =
|
|
326
|
+
(
|
|
327
|
+
user as
|
|
328
|
+
unknown as
|
|
329
|
+
Record<string, unknown>
|
|
330
|
+
)[field];
|
|
331
|
+
|
|
332
|
+
if (
|
|
333
|
+
typeof value ===
|
|
334
|
+
"string"
|
|
335
|
+
) {
|
|
336
|
+
const role =
|
|
337
|
+
value.trim();
|
|
338
|
+
|
|
339
|
+
return role
|
|
340
|
+
? [
|
|
341
|
+
role,
|
|
342
|
+
]
|
|
343
|
+
: [];
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
if (
|
|
347
|
+
Array.isArray(
|
|
348
|
+
value
|
|
349
|
+
)
|
|
350
|
+
) {
|
|
351
|
+
return value
|
|
352
|
+
.filter(
|
|
353
|
+
(
|
|
354
|
+
role
|
|
355
|
+
): role is string =>
|
|
356
|
+
typeof role ===
|
|
357
|
+
"string"
|
|
358
|
+
)
|
|
359
|
+
.map(
|
|
360
|
+
(role) =>
|
|
361
|
+
role.trim()
|
|
362
|
+
)
|
|
363
|
+
.filter(
|
|
364
|
+
Boolean
|
|
365
|
+
);
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
return [];
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
function isGuardAuthUser(
|
|
372
|
+
value: unknown
|
|
373
|
+
): value is AuthUser {
|
|
374
|
+
if (
|
|
375
|
+
!value ||
|
|
376
|
+
typeof value !==
|
|
377
|
+
"object" ||
|
|
378
|
+
Array.isArray(
|
|
379
|
+
value
|
|
380
|
+
)
|
|
381
|
+
) {
|
|
382
|
+
return false;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
const id =
|
|
386
|
+
(
|
|
387
|
+
value as
|
|
388
|
+
Record<string, unknown>
|
|
389
|
+
).id;
|
|
390
|
+
|
|
391
|
+
return (
|
|
392
|
+
typeof id ===
|
|
393
|
+
"string" &&
|
|
394
|
+
id.trim().length >
|
|
395
|
+
0
|
|
396
|
+
) || (
|
|
397
|
+
typeof id ===
|
|
398
|
+
"number" &&
|
|
399
|
+
Number.isFinite(
|
|
400
|
+
id
|
|
401
|
+
)
|
|
402
|
+
);
|
|
403
|
+
}
|
|
@@ -0,0 +1,376 @@
|
|
|
1
|
+
import {
|
|
2
|
+
randomUUID,
|
|
3
|
+
} from "node:crypto";
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
createSession,
|
|
7
|
+
destroySession,
|
|
8
|
+
getSession as readSession,
|
|
9
|
+
type SessionClaims,
|
|
10
|
+
type SessionCookieOptions,
|
|
11
|
+
} from "./session.js";
|
|
12
|
+
|
|
13
|
+
export interface AuthUser {
|
|
14
|
+
id: string | number;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface AuthSession<
|
|
18
|
+
TUser extends AuthUser = AuthUser,
|
|
19
|
+
TData extends object = Record<string, never>
|
|
20
|
+
> extends SessionClaims {
|
|
21
|
+
sid: string;
|
|
22
|
+
user: TUser;
|
|
23
|
+
data?: TData;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface AuthOptions
|
|
27
|
+
extends SessionCookieOptions {}
|
|
28
|
+
|
|
29
|
+
export interface AuthLoginOptions<
|
|
30
|
+
TData extends object = Record<string, never>
|
|
31
|
+
> extends AuthOptions {
|
|
32
|
+
data?: TData;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface AuthApi<
|
|
36
|
+
TUser extends AuthUser = AuthUser,
|
|
37
|
+
TData extends object = Record<string, never>
|
|
38
|
+
> {
|
|
39
|
+
auth(
|
|
40
|
+
options?: AuthOptions
|
|
41
|
+
): Promise<AuthSession<TUser, TData> | null>;
|
|
42
|
+
getSession(
|
|
43
|
+
options?: AuthOptions
|
|
44
|
+
): Promise<AuthSession<TUser, TData> | null>;
|
|
45
|
+
login(
|
|
46
|
+
user: TUser,
|
|
47
|
+
options?: AuthLoginOptions<TData>
|
|
48
|
+
): Promise<AuthSession<TUser, TData>>;
|
|
49
|
+
logout(
|
|
50
|
+
options?: AuthOptions
|
|
51
|
+
): Promise<void>;
|
|
52
|
+
rotateSession(
|
|
53
|
+
options?: AuthOptions
|
|
54
|
+
): Promise<AuthSession<TUser, TData> | null>;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
interface AuthSessionPayload<
|
|
58
|
+
TUser extends AuthUser,
|
|
59
|
+
TData extends object
|
|
60
|
+
> {
|
|
61
|
+
sid: string;
|
|
62
|
+
user: TUser;
|
|
63
|
+
data?: TData;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export async function auth<
|
|
67
|
+
TUser extends AuthUser = AuthUser,
|
|
68
|
+
TData extends object = Record<string, never>
|
|
69
|
+
>(
|
|
70
|
+
options: AuthOptions = {}
|
|
71
|
+
): Promise<AuthSession<TUser, TData> | null> {
|
|
72
|
+
const session =
|
|
73
|
+
await readSession<
|
|
74
|
+
AuthSessionPayload<
|
|
75
|
+
TUser,
|
|
76
|
+
TData
|
|
77
|
+
>
|
|
78
|
+
>(
|
|
79
|
+
options
|
|
80
|
+
);
|
|
81
|
+
|
|
82
|
+
if (!session) {
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (
|
|
87
|
+
typeof session.sid !==
|
|
88
|
+
"string" ||
|
|
89
|
+
session.sid.trim().length ===
|
|
90
|
+
0 ||
|
|
91
|
+
!isAuthUser(
|
|
92
|
+
session.user
|
|
93
|
+
)
|
|
94
|
+
) {
|
|
95
|
+
return null;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if (
|
|
99
|
+
session.data !== undefined &&
|
|
100
|
+
!isPlainObject(
|
|
101
|
+
session.data
|
|
102
|
+
)
|
|
103
|
+
) {
|
|
104
|
+
return null;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
return session as
|
|
108
|
+
AuthSession<
|
|
109
|
+
TUser,
|
|
110
|
+
TData
|
|
111
|
+
>;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export async function getSession<
|
|
115
|
+
TUser extends AuthUser = AuthUser,
|
|
116
|
+
TData extends object = Record<string, never>
|
|
117
|
+
>(
|
|
118
|
+
options: AuthOptions = {}
|
|
119
|
+
): Promise<AuthSession<TUser, TData> | null> {
|
|
120
|
+
return auth<
|
|
121
|
+
TUser,
|
|
122
|
+
TData
|
|
123
|
+
>(
|
|
124
|
+
options
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export async function login<
|
|
129
|
+
TUser extends AuthUser,
|
|
130
|
+
TData extends object = Record<string, never>
|
|
131
|
+
>(
|
|
132
|
+
user: TUser,
|
|
133
|
+
options:
|
|
134
|
+
AuthLoginOptions<TData> = {}
|
|
135
|
+
): Promise<AuthSession<TUser, TData>> {
|
|
136
|
+
assertAuthUser(
|
|
137
|
+
user
|
|
138
|
+
);
|
|
139
|
+
|
|
140
|
+
if (
|
|
141
|
+
options.data !== undefined &&
|
|
142
|
+
!isPlainObject(
|
|
143
|
+
options.data
|
|
144
|
+
)
|
|
145
|
+
) {
|
|
146
|
+
throw new TypeError(
|
|
147
|
+
"BCP Auth: session data must be a plain object."
|
|
148
|
+
);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const {
|
|
152
|
+
data,
|
|
153
|
+
...sessionOptions
|
|
154
|
+
} = options;
|
|
155
|
+
const payload:
|
|
156
|
+
AuthSessionPayload<
|
|
157
|
+
TUser,
|
|
158
|
+
TData
|
|
159
|
+
> = {
|
|
160
|
+
sid:
|
|
161
|
+
randomUUID(),
|
|
162
|
+
user,
|
|
163
|
+
...(data === undefined
|
|
164
|
+
? {}
|
|
165
|
+
: {
|
|
166
|
+
data,
|
|
167
|
+
}),
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
await createSession(
|
|
171
|
+
payload,
|
|
172
|
+
sessionOptions
|
|
173
|
+
);
|
|
174
|
+
|
|
175
|
+
const session =
|
|
176
|
+
await auth<
|
|
177
|
+
TUser,
|
|
178
|
+
TData
|
|
179
|
+
>(
|
|
180
|
+
sessionOptions
|
|
181
|
+
);
|
|
182
|
+
|
|
183
|
+
if (!session) {
|
|
184
|
+
throw new Error(
|
|
185
|
+
"BCP Auth: login created a session but it could not be read back."
|
|
186
|
+
);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
return session;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
export async function logout(
|
|
193
|
+
options: AuthOptions = {}
|
|
194
|
+
): Promise<void> {
|
|
195
|
+
await destroySession(
|
|
196
|
+
options
|
|
197
|
+
);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
export async function rotateSession<
|
|
201
|
+
TUser extends AuthUser = AuthUser,
|
|
202
|
+
TData extends object = Record<string, never>
|
|
203
|
+
>(
|
|
204
|
+
options: AuthOptions = {}
|
|
205
|
+
): Promise<AuthSession<TUser, TData> | null> {
|
|
206
|
+
const current =
|
|
207
|
+
await auth<
|
|
208
|
+
TUser,
|
|
209
|
+
TData
|
|
210
|
+
>(
|
|
211
|
+
options
|
|
212
|
+
);
|
|
213
|
+
|
|
214
|
+
if (!current) {
|
|
215
|
+
return null;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
return login<
|
|
219
|
+
TUser,
|
|
220
|
+
TData
|
|
221
|
+
>(
|
|
222
|
+
current.user,
|
|
223
|
+
{
|
|
224
|
+
...options,
|
|
225
|
+
issuer:
|
|
226
|
+
options.issuer ??
|
|
227
|
+
current.iss,
|
|
228
|
+
audience:
|
|
229
|
+
options.audience ??
|
|
230
|
+
current.aud,
|
|
231
|
+
...(current.data === undefined
|
|
232
|
+
? {}
|
|
233
|
+
: {
|
|
234
|
+
data:
|
|
235
|
+
current.data,
|
|
236
|
+
}),
|
|
237
|
+
}
|
|
238
|
+
);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
export function createAuth<
|
|
242
|
+
TUser extends AuthUser = AuthUser,
|
|
243
|
+
TData extends object = Record<string, never>
|
|
244
|
+
>(
|
|
245
|
+
defaults: AuthOptions = {}
|
|
246
|
+
): AuthApi<TUser, TData> {
|
|
247
|
+
return {
|
|
248
|
+
auth: (
|
|
249
|
+
options = {}
|
|
250
|
+
) =>
|
|
251
|
+
auth<
|
|
252
|
+
TUser,
|
|
253
|
+
TData
|
|
254
|
+
>({
|
|
255
|
+
...defaults,
|
|
256
|
+
...options,
|
|
257
|
+
}),
|
|
258
|
+
getSession: (
|
|
259
|
+
options = {}
|
|
260
|
+
) =>
|
|
261
|
+
auth<
|
|
262
|
+
TUser,
|
|
263
|
+
TData
|
|
264
|
+
>({
|
|
265
|
+
...defaults,
|
|
266
|
+
...options,
|
|
267
|
+
}),
|
|
268
|
+
login: (
|
|
269
|
+
user,
|
|
270
|
+
options = {}
|
|
271
|
+
) => {
|
|
272
|
+
const {
|
|
273
|
+
data,
|
|
274
|
+
...sessionOptions
|
|
275
|
+
} = options;
|
|
276
|
+
|
|
277
|
+
return login<
|
|
278
|
+
TUser,
|
|
279
|
+
TData
|
|
280
|
+
>(
|
|
281
|
+
user,
|
|
282
|
+
{
|
|
283
|
+
...defaults,
|
|
284
|
+
...sessionOptions,
|
|
285
|
+
...(data === undefined
|
|
286
|
+
? {}
|
|
287
|
+
: {
|
|
288
|
+
data,
|
|
289
|
+
}),
|
|
290
|
+
}
|
|
291
|
+
);
|
|
292
|
+
},
|
|
293
|
+
logout: (
|
|
294
|
+
options = {}
|
|
295
|
+
) =>
|
|
296
|
+
logout({
|
|
297
|
+
...defaults,
|
|
298
|
+
...options,
|
|
299
|
+
}),
|
|
300
|
+
rotateSession: (
|
|
301
|
+
options = {}
|
|
302
|
+
) =>
|
|
303
|
+
rotateSession<
|
|
304
|
+
TUser,
|
|
305
|
+
TData
|
|
306
|
+
>({
|
|
307
|
+
...defaults,
|
|
308
|
+
...options,
|
|
309
|
+
}),
|
|
310
|
+
};
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
function assertAuthUser(
|
|
314
|
+
value: unknown
|
|
315
|
+
): asserts value is AuthUser {
|
|
316
|
+
if (
|
|
317
|
+
!isAuthUser(
|
|
318
|
+
value
|
|
319
|
+
)
|
|
320
|
+
) {
|
|
321
|
+
throw new TypeError(
|
|
322
|
+
"BCP Auth: user must be a plain object with a non-empty string or finite numeric id."
|
|
323
|
+
);
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
function isAuthUser(
|
|
328
|
+
value: unknown
|
|
329
|
+
): value is AuthUser {
|
|
330
|
+
if (
|
|
331
|
+
!isPlainObject(
|
|
332
|
+
value
|
|
333
|
+
)
|
|
334
|
+
) {
|
|
335
|
+
return false;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
const id =
|
|
339
|
+
value.id;
|
|
340
|
+
|
|
341
|
+
return (
|
|
342
|
+
typeof id === "string" &&
|
|
343
|
+
id.trim().length > 0
|
|
344
|
+
) || (
|
|
345
|
+
typeof id === "number" &&
|
|
346
|
+
Number.isFinite(
|
|
347
|
+
id
|
|
348
|
+
)
|
|
349
|
+
);
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
function isPlainObject(
|
|
353
|
+
value: unknown
|
|
354
|
+
): value is Record<string, unknown> {
|
|
355
|
+
if (
|
|
356
|
+
value === null ||
|
|
357
|
+
typeof value !== "object" ||
|
|
358
|
+
Array.isArray(
|
|
359
|
+
value
|
|
360
|
+
)
|
|
361
|
+
) {
|
|
362
|
+
return false;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
const prototype =
|
|
366
|
+
Object.getPrototypeOf(
|
|
367
|
+
value
|
|
368
|
+
);
|
|
369
|
+
|
|
370
|
+
return (
|
|
371
|
+
prototype ===
|
|
372
|
+
Object.prototype ||
|
|
373
|
+
prototype ===
|
|
374
|
+
null
|
|
375
|
+
);
|
|
376
|
+
}
|