@alepha/react 0.5.1 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.browser.cjs +23 -19
- package/dist/index.browser.js +18 -0
- package/dist/index.cjs +419 -340
- package/dist/{index.d.cts → index.d.ts} +591 -420
- package/dist/index.js +554 -0
- package/dist/{useRouterState-BlKHWZwk.cjs → useAuth-DOVx2kqa.cjs} +243 -102
- package/dist/{useRouterState-CvFCmaq7.mjs → useAuth-i7wbKVrt.js} +214 -77
- package/package.json +21 -23
- package/src/components/Link.tsx +22 -0
- package/src/components/NestedView.tsx +2 -2
- package/src/constants/SSID.ts +1 -0
- package/src/contexts/RouterContext.ts +2 -2
- package/src/descriptors/$auth.ts +28 -0
- package/src/descriptors/$page.ts +57 -3
- package/src/errors/RedirectionError.ts +7 -0
- package/src/hooks/useAuth.ts +29 -0
- package/src/hooks/useInject.ts +3 -3
- package/src/index.browser.ts +3 -1
- package/src/index.shared.ts +14 -3
- package/src/index.ts +23 -6
- package/src/providers/ReactAuthProvider.ts +410 -0
- package/src/providers/ReactBrowserProvider.ts +41 -19
- package/src/providers/ReactServerProvider.ts +106 -49
- package/src/services/Auth.ts +45 -0
- package/src/services/Router.ts +154 -41
- package/dist/index.browser.mjs +0 -17
- package/dist/index.d.mts +0 -872
- package/dist/index.mjs +0 -477
- package/src/providers/ReactSessionProvider.ts +0 -363
package/dist/index.cjs
CHANGED
|
@@ -2,19 +2,316 @@
|
|
|
2
2
|
|
|
3
3
|
var core = require('@alepha/core');
|
|
4
4
|
var server = require('@alepha/server');
|
|
5
|
-
var
|
|
5
|
+
var useAuth = require('./useAuth-DOVx2kqa.cjs');
|
|
6
|
+
var openidClient = require('openid-client');
|
|
6
7
|
var node_fs = require('node:fs');
|
|
7
8
|
var promises = require('node:fs/promises');
|
|
8
9
|
var node_path = require('node:path');
|
|
9
10
|
var server$1 = require('react-dom/server');
|
|
10
|
-
|
|
11
|
-
var cache = require('@alepha/cache');
|
|
12
|
-
var security = require('@alepha/security');
|
|
13
|
-
var openidClient = require('openid-client');
|
|
11
|
+
require('react/jsx-runtime');
|
|
14
12
|
require('react');
|
|
15
13
|
require('react-dom/client');
|
|
16
14
|
require('path-to-regexp');
|
|
17
15
|
|
|
16
|
+
class ReactAuthProvider {
|
|
17
|
+
log = core.$logger();
|
|
18
|
+
alepha = core.$inject(core.Alepha);
|
|
19
|
+
fastifyCookieProvider = core.$inject(server.FastifyCookieProvider);
|
|
20
|
+
authProviders = [];
|
|
21
|
+
authorizationCode = server.$cookie({
|
|
22
|
+
name: "authorizationCode",
|
|
23
|
+
ttl: { minutes: 15 },
|
|
24
|
+
httpOnly: true,
|
|
25
|
+
schema: core.t.object({
|
|
26
|
+
codeVerifier: core.t.optional(core.t.string({ size: "long" })),
|
|
27
|
+
redirectUri: core.t.optional(core.t.string({ size: "long" }))
|
|
28
|
+
})
|
|
29
|
+
});
|
|
30
|
+
tokens = server.$cookie({
|
|
31
|
+
name: "tokens",
|
|
32
|
+
ttl: { days: 1 },
|
|
33
|
+
httpOnly: true,
|
|
34
|
+
compress: true,
|
|
35
|
+
schema: core.t.object({
|
|
36
|
+
access_token: core.t.optional(core.t.string({ size: "rich" })),
|
|
37
|
+
expires_in: core.t.optional(core.t.number()),
|
|
38
|
+
refresh_token: core.t.optional(core.t.string({ size: "rich" })),
|
|
39
|
+
id_token: core.t.optional(core.t.string({ size: "rich" })),
|
|
40
|
+
scope: core.t.optional(core.t.string()),
|
|
41
|
+
issued_at: core.t.optional(core.t.number())
|
|
42
|
+
})
|
|
43
|
+
});
|
|
44
|
+
user = server.$cookie({
|
|
45
|
+
name: "user",
|
|
46
|
+
ttl: { days: 1 },
|
|
47
|
+
schema: core.t.object({
|
|
48
|
+
id: core.t.string(),
|
|
49
|
+
name: core.t.optional(core.t.string()),
|
|
50
|
+
email: core.t.optional(core.t.string())
|
|
51
|
+
})
|
|
52
|
+
});
|
|
53
|
+
configure = core.$hook({
|
|
54
|
+
name: "configure",
|
|
55
|
+
handler: async () => {
|
|
56
|
+
const auths = this.alepha.getDescriptorValues(useAuth.$auth);
|
|
57
|
+
for (const auth of auths) {
|
|
58
|
+
const options = auth.value.options;
|
|
59
|
+
if (options.oidc) {
|
|
60
|
+
this.authProviders.push({
|
|
61
|
+
name: options.name ?? auth.key,
|
|
62
|
+
redirectUri: options.oidc.redirectUri ?? "/api/_oauth/callback",
|
|
63
|
+
client: await openidClient.discovery(
|
|
64
|
+
new URL(options.oidc.issuer),
|
|
65
|
+
options.oidc.clientId,
|
|
66
|
+
{
|
|
67
|
+
client_secret: options.oidc.clientSecret
|
|
68
|
+
},
|
|
69
|
+
void 0,
|
|
70
|
+
{
|
|
71
|
+
execute: [openidClient.allowInsecureRequests]
|
|
72
|
+
}
|
|
73
|
+
)
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
});
|
|
79
|
+
/**
|
|
80
|
+
* Configure Fastify to forward Session Access Token to Header Authorization.
|
|
81
|
+
*/
|
|
82
|
+
configureFastify = core.$hook({
|
|
83
|
+
name: "configure:fastify",
|
|
84
|
+
after: this.fastifyCookieProvider,
|
|
85
|
+
handler: async (app) => {
|
|
86
|
+
app.addHook("onRequest", async (req) => {
|
|
87
|
+
if (req.cookies && !this.isViteFile(req.url) && !!this.authProviders.length) {
|
|
88
|
+
const tokens = await this.refresh(req.cookies);
|
|
89
|
+
if (tokens) {
|
|
90
|
+
req.headers.authorization = `Bearer ${tokens.access_token}`;
|
|
91
|
+
}
|
|
92
|
+
if (this.user.get(req.cookies) && !this.tokens.get(req.cookies)) {
|
|
93
|
+
this.user.del(req.cookies);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
});
|
|
99
|
+
/**
|
|
100
|
+
*
|
|
101
|
+
* @param cookies
|
|
102
|
+
* @protected
|
|
103
|
+
*/
|
|
104
|
+
async refresh(cookies) {
|
|
105
|
+
const now = Date.now();
|
|
106
|
+
const tokens = this.tokens.get(cookies);
|
|
107
|
+
if (!tokens) {
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
if (tokens.expires_in && tokens.issued_at) {
|
|
111
|
+
const expiresAt = tokens.issued_at + (tokens.expires_in - 10) * 1e3;
|
|
112
|
+
if (expiresAt < now) {
|
|
113
|
+
if (tokens.refresh_token) {
|
|
114
|
+
try {
|
|
115
|
+
const newTokens = await openidClient.refreshTokenGrant(
|
|
116
|
+
this.authProviders[0].client,
|
|
117
|
+
tokens.refresh_token
|
|
118
|
+
);
|
|
119
|
+
this.tokens.set(cookies, {
|
|
120
|
+
...newTokens,
|
|
121
|
+
issued_at: Date.now()
|
|
122
|
+
});
|
|
123
|
+
return newTokens;
|
|
124
|
+
} catch (e) {
|
|
125
|
+
this.log.warn(e, "Failed to refresh token -");
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
this.tokens.del(cookies);
|
|
129
|
+
this.user.del(cookies);
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
if (!tokens.issued_at && tokens.access_token) {
|
|
134
|
+
this.tokens.del(cookies);
|
|
135
|
+
this.user.del(cookies);
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
return tokens;
|
|
139
|
+
}
|
|
140
|
+
/**
|
|
141
|
+
*
|
|
142
|
+
*/
|
|
143
|
+
login = server.$route({
|
|
144
|
+
security: false,
|
|
145
|
+
private: true,
|
|
146
|
+
url: "/_oauth/login",
|
|
147
|
+
group: "auth",
|
|
148
|
+
method: "GET",
|
|
149
|
+
schema: {
|
|
150
|
+
query: core.t.object({
|
|
151
|
+
redirect: core.t.optional(core.t.string()),
|
|
152
|
+
provider: core.t.optional(core.t.string())
|
|
153
|
+
})
|
|
154
|
+
},
|
|
155
|
+
handler: async ({ query, cookies, url }) => {
|
|
156
|
+
const { client } = this.provider(query.provider);
|
|
157
|
+
const codeVerifier = openidClient.randomPKCECodeVerifier();
|
|
158
|
+
const codeChallenge = await openidClient.calculatePKCECodeChallenge(codeVerifier);
|
|
159
|
+
const scope = "openid profile email";
|
|
160
|
+
let redirect_uri = this.authProviders[0].redirectUri;
|
|
161
|
+
if (redirect_uri.startsWith("/")) {
|
|
162
|
+
redirect_uri = `${url.protocol}://${url.host}${redirect_uri}`;
|
|
163
|
+
}
|
|
164
|
+
const parameters = {
|
|
165
|
+
redirect_uri,
|
|
166
|
+
scope,
|
|
167
|
+
code_challenge: codeChallenge,
|
|
168
|
+
code_challenge_method: "S256"
|
|
169
|
+
};
|
|
170
|
+
this.authorizationCode.set(cookies, {
|
|
171
|
+
codeVerifier,
|
|
172
|
+
redirectUri: query.redirect ?? "/"
|
|
173
|
+
});
|
|
174
|
+
return new Response("", {
|
|
175
|
+
status: 302,
|
|
176
|
+
headers: {
|
|
177
|
+
Location: openidClient.buildAuthorizationUrl(client, parameters).toString()
|
|
178
|
+
}
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
});
|
|
182
|
+
/**
|
|
183
|
+
*
|
|
184
|
+
*/
|
|
185
|
+
callback = server.$route({
|
|
186
|
+
security: false,
|
|
187
|
+
private: true,
|
|
188
|
+
url: "/_oauth/callback",
|
|
189
|
+
group: "auth",
|
|
190
|
+
method: "GET",
|
|
191
|
+
schema: {
|
|
192
|
+
query: core.t.object({
|
|
193
|
+
provider: core.t.optional(core.t.string())
|
|
194
|
+
})
|
|
195
|
+
},
|
|
196
|
+
handler: async ({ url, cookies, query }) => {
|
|
197
|
+
const { client } = this.provider(query.provider);
|
|
198
|
+
const authorizationCode = this.authorizationCode.get(cookies);
|
|
199
|
+
if (!authorizationCode) {
|
|
200
|
+
throw new server.BadRequestError("Missing code verifier");
|
|
201
|
+
}
|
|
202
|
+
const tokens = await openidClient.authorizationCodeGrant(client, url, {
|
|
203
|
+
pkceCodeVerifier: authorizationCode.codeVerifier
|
|
204
|
+
});
|
|
205
|
+
this.authorizationCode.del(cookies);
|
|
206
|
+
this.tokens.set(cookies, {
|
|
207
|
+
...tokens,
|
|
208
|
+
issued_at: Date.now()
|
|
209
|
+
});
|
|
210
|
+
const user = this.userFromAccessToken(tokens.access_token);
|
|
211
|
+
if (user) {
|
|
212
|
+
this.user.set(cookies, user);
|
|
213
|
+
}
|
|
214
|
+
return Response.redirect(authorizationCode.redirectUri ?? "/");
|
|
215
|
+
}
|
|
216
|
+
});
|
|
217
|
+
/**
|
|
218
|
+
*
|
|
219
|
+
* @param accessToken
|
|
220
|
+
* @protected
|
|
221
|
+
*/
|
|
222
|
+
userFromAccessToken(accessToken) {
|
|
223
|
+
try {
|
|
224
|
+
const parts = accessToken.split(".");
|
|
225
|
+
if (parts.length !== 3) {
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
const payload = parts[1];
|
|
229
|
+
const decoded = JSON.parse(atob(payload));
|
|
230
|
+
if (!decoded.sub) {
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
return {
|
|
234
|
+
id: decoded.sub,
|
|
235
|
+
name: decoded.name,
|
|
236
|
+
email: decoded.email
|
|
237
|
+
// organization
|
|
238
|
+
// ...
|
|
239
|
+
};
|
|
240
|
+
} catch (e) {
|
|
241
|
+
this.log.warn(e, "Failed to decode access token");
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
/**
|
|
245
|
+
*
|
|
246
|
+
*/
|
|
247
|
+
logout = server.$route({
|
|
248
|
+
security: false,
|
|
249
|
+
private: true,
|
|
250
|
+
url: "/_oauth/logout",
|
|
251
|
+
group: "auth",
|
|
252
|
+
method: "GET",
|
|
253
|
+
schema: {
|
|
254
|
+
query: core.t.object({
|
|
255
|
+
redirect: core.t.optional(core.t.string()),
|
|
256
|
+
provider: core.t.optional(core.t.string())
|
|
257
|
+
})
|
|
258
|
+
},
|
|
259
|
+
handler: async ({ query, cookies }) => {
|
|
260
|
+
const { client } = this.provider(query.provider);
|
|
261
|
+
const tokens = this.tokens.get(cookies);
|
|
262
|
+
const idToken = tokens?.id_token;
|
|
263
|
+
const redirect = query.redirect ?? "/";
|
|
264
|
+
const params = new URLSearchParams();
|
|
265
|
+
params.set("post_logout_redirect_uri", redirect);
|
|
266
|
+
if (idToken) {
|
|
267
|
+
params.set("id_token_hint", idToken);
|
|
268
|
+
}
|
|
269
|
+
this.tokens.del(cookies);
|
|
270
|
+
this.user.del(cookies);
|
|
271
|
+
return Response.redirect(openidClient.buildEndSessionUrl(client, params).toString());
|
|
272
|
+
}
|
|
273
|
+
});
|
|
274
|
+
/**
|
|
275
|
+
*
|
|
276
|
+
* @param name
|
|
277
|
+
* @protected
|
|
278
|
+
*/
|
|
279
|
+
provider(name) {
|
|
280
|
+
if (!name) {
|
|
281
|
+
const client = this.authProviders[0];
|
|
282
|
+
if (!client) {
|
|
283
|
+
throw new server.BadRequestError("Client name is required");
|
|
284
|
+
}
|
|
285
|
+
return client;
|
|
286
|
+
}
|
|
287
|
+
const authProvider = this.authProviders.find(
|
|
288
|
+
(provider) => provider.name === name
|
|
289
|
+
);
|
|
290
|
+
if (!authProvider) {
|
|
291
|
+
throw new server.BadRequestError(`Client ${name} not found`);
|
|
292
|
+
}
|
|
293
|
+
return authProvider;
|
|
294
|
+
}
|
|
295
|
+
/**
|
|
296
|
+
*
|
|
297
|
+
* @param file
|
|
298
|
+
* @protected
|
|
299
|
+
*/
|
|
300
|
+
isViteFile(file) {
|
|
301
|
+
const [pathname] = file.split("?");
|
|
302
|
+
if (pathname.startsWith("/docs")) {
|
|
303
|
+
return false;
|
|
304
|
+
}
|
|
305
|
+
if (pathname.match(/\.\w{2,5}$/)) {
|
|
306
|
+
return true;
|
|
307
|
+
}
|
|
308
|
+
if (pathname.startsWith("/@")) {
|
|
309
|
+
return true;
|
|
310
|
+
}
|
|
311
|
+
return false;
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
18
315
|
const envSchema$1 = core.t.object({
|
|
19
316
|
REACT_SERVER_DIST: core.t.string({ default: "client" }),
|
|
20
317
|
REACT_SERVER_PREFIX: core.t.string({ default: "" }),
|
|
@@ -24,7 +321,7 @@ const envSchema$1 = core.t.object({
|
|
|
24
321
|
class ReactServerProvider {
|
|
25
322
|
log = core.$logger();
|
|
26
323
|
alepha = core.$inject(core.Alepha);
|
|
27
|
-
router = core.$inject(
|
|
324
|
+
router = core.$inject(useAuth.Router);
|
|
28
325
|
server = core.$inject(server.ServerProvider);
|
|
29
326
|
env = core.$inject(envSchema$1);
|
|
30
327
|
configure = core.$hook({
|
|
@@ -41,46 +338,67 @@ class ReactServerProvider {
|
|
|
41
338
|
return;
|
|
42
339
|
}
|
|
43
340
|
if (process.env.VITE_ALEPHA_DEV === "true") {
|
|
44
|
-
this.log.info("SSR
|
|
45
|
-
const templateUrl =
|
|
341
|
+
this.log.info("SSR (vite) OK");
|
|
342
|
+
const templateUrl = "http://127.0.0.1:5173/index.html";
|
|
46
343
|
this.log.debug(`Fetch template from ${templateUrl}`);
|
|
47
344
|
const route2 = this.createHandler(
|
|
48
|
-
() => fetch(templateUrl).then((it) => it.text()).catch(() => void 0)
|
|
345
|
+
() => fetch(templateUrl).then((it) => it.text()).catch(() => void 0).then((it) => it ? this.checkTemplate(it) : void 0)
|
|
49
346
|
);
|
|
50
347
|
await this.server.route(route2);
|
|
51
348
|
await this.server.route({
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
handler: route2.handler
|
|
349
|
+
...route2,
|
|
350
|
+
url: "*"
|
|
55
351
|
});
|
|
56
352
|
return;
|
|
57
353
|
}
|
|
58
|
-
const maybe = [
|
|
59
|
-
node_path.join(process.cwd(), this.env.REACT_SERVER_DIST),
|
|
60
|
-
node_path.join(process.cwd(), "..", this.env.REACT_SERVER_DIST),
|
|
61
|
-
node_path.join(process.cwd(), "dist", this.env.REACT_SERVER_DIST)
|
|
62
|
-
];
|
|
63
354
|
let root = "";
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
355
|
+
if (!this.alepha.isServerless()) {
|
|
356
|
+
const maybe = [
|
|
357
|
+
node_path.join(process.cwd(), this.env.REACT_SERVER_DIST),
|
|
358
|
+
node_path.join(process.cwd(), "..", this.env.REACT_SERVER_DIST)
|
|
359
|
+
];
|
|
360
|
+
for (const it of maybe) {
|
|
361
|
+
if (node_fs.existsSync(it)) {
|
|
362
|
+
root = it;
|
|
363
|
+
break;
|
|
364
|
+
}
|
|
68
365
|
}
|
|
366
|
+
if (!root) {
|
|
367
|
+
this.log.warn("Missing static files, SSR will be disabled");
|
|
368
|
+
return;
|
|
369
|
+
}
|
|
370
|
+
await this.server.serve(this.createStaticHandler(root));
|
|
69
371
|
}
|
|
70
|
-
|
|
71
|
-
this.
|
|
72
|
-
|
|
73
|
-
}
|
|
74
|
-
await this.server.serve(this.createStaticHandler(root));
|
|
75
|
-
const template = await promises.readFile(node_path.join(root, "index.html"), "utf-8");
|
|
372
|
+
const template = this.checkTemplate(
|
|
373
|
+
this.alepha.state("ReactServerProvider.template") ?? await promises.readFile(node_path.join(root, "index.html"), "utf-8")
|
|
374
|
+
);
|
|
76
375
|
const route = this.createHandler(async () => template);
|
|
77
376
|
await this.server.route(route);
|
|
78
377
|
await this.server.route({
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
handler: route.handler
|
|
378
|
+
...route,
|
|
379
|
+
url: "*"
|
|
82
380
|
});
|
|
83
381
|
}
|
|
382
|
+
/**
|
|
383
|
+
* Check if the template contains the outlet.
|
|
384
|
+
*
|
|
385
|
+
* @param template
|
|
386
|
+
* @protected
|
|
387
|
+
*/
|
|
388
|
+
checkTemplate(template) {
|
|
389
|
+
if (!template.includes(this.env.REACT_SSR_OUTLET)) {
|
|
390
|
+
if (!template.includes('<div id="root"></div>')) {
|
|
391
|
+
throw new Error(
|
|
392
|
+
`Missing React SSR outlet in index.html, please add ${this.env.REACT_SSR_OUTLET} to the index.html file`
|
|
393
|
+
);
|
|
394
|
+
}
|
|
395
|
+
return template.replace(
|
|
396
|
+
`<div id="root"></div>`,
|
|
397
|
+
`<div id="root">${this.env.REACT_SSR_OUTLET}</div>`
|
|
398
|
+
);
|
|
399
|
+
}
|
|
400
|
+
return template;
|
|
401
|
+
}
|
|
84
402
|
/**
|
|
85
403
|
*
|
|
86
404
|
* @param root
|
|
@@ -105,22 +423,26 @@ class ReactServerProvider {
|
|
|
105
423
|
*/
|
|
106
424
|
createHandler(templateLoader) {
|
|
107
425
|
return {
|
|
426
|
+
method: "GET",
|
|
108
427
|
url: "/",
|
|
109
|
-
handler: async (
|
|
428
|
+
handler: async (ctx) => {
|
|
110
429
|
const template = await templateLoader();
|
|
111
430
|
if (!template) {
|
|
112
431
|
return new Response("Not found", { status: 404 });
|
|
113
432
|
}
|
|
114
|
-
const response = this.notFoundHandler(url);
|
|
433
|
+
const response = this.notFoundHandler(ctx.url);
|
|
115
434
|
if (response) {
|
|
116
435
|
return response;
|
|
117
436
|
}
|
|
118
|
-
return await this.ssr(url, template,
|
|
437
|
+
return await this.ssr(ctx.url, template, {
|
|
438
|
+
user: ctx.user,
|
|
439
|
+
cookies: ctx.cookies
|
|
440
|
+
});
|
|
119
441
|
}
|
|
120
442
|
};
|
|
121
443
|
}
|
|
122
444
|
processDescriptors() {
|
|
123
|
-
const pages = this.alepha.getDescriptorValues(
|
|
445
|
+
const pages = this.alepha.getDescriptorValues(useAuth.$page);
|
|
124
446
|
for (const { key, instance, value } of pages) {
|
|
125
447
|
instance[key].render = async (options = {}) => {
|
|
126
448
|
const name = value.options.name ?? key;
|
|
@@ -136,7 +458,8 @@ class ReactServerProvider {
|
|
|
136
458
|
this.router.root({
|
|
137
459
|
layers,
|
|
138
460
|
pathname: "",
|
|
139
|
-
search: ""
|
|
461
|
+
search: "",
|
|
462
|
+
context: {}
|
|
140
463
|
})
|
|
141
464
|
);
|
|
142
465
|
};
|
|
@@ -148,7 +471,7 @@ class ReactServerProvider {
|
|
|
148
471
|
* @protected
|
|
149
472
|
*/
|
|
150
473
|
notFoundHandler(url) {
|
|
151
|
-
if (url.match(/\.\w+$/)) {
|
|
474
|
+
if (url.pathname.match(/\.\w+$/)) {
|
|
152
475
|
return new Response("Not found", { status: 404 });
|
|
153
476
|
}
|
|
154
477
|
}
|
|
@@ -156,12 +479,15 @@ class ReactServerProvider {
|
|
|
156
479
|
*
|
|
157
480
|
* @param url
|
|
158
481
|
* @param template
|
|
159
|
-
* @param
|
|
482
|
+
* @param page
|
|
160
483
|
*/
|
|
161
|
-
async ssr(url, template = this.env.REACT_SSR_OUTLET,
|
|
162
|
-
const { element, layers, redirect } = await this.router.render(
|
|
163
|
-
|
|
164
|
-
|
|
484
|
+
async ssr(url, template = this.env.REACT_SSR_OUTLET, page = {}) {
|
|
485
|
+
const { element, layers, redirect, context } = await this.router.render(
|
|
486
|
+
url.pathname + url.search,
|
|
487
|
+
{
|
|
488
|
+
args: page
|
|
489
|
+
}
|
|
490
|
+
);
|
|
165
491
|
if (redirect) {
|
|
166
492
|
return new Response("", {
|
|
167
493
|
status: 302,
|
|
@@ -177,324 +503,77 @@ class ReactServerProvider {
|
|
|
177
503
|
index: void 0,
|
|
178
504
|
path: void 0,
|
|
179
505
|
element: void 0
|
|
180
|
-
}))
|
|
181
|
-
session: {
|
|
182
|
-
user: user ? {
|
|
183
|
-
id: user.id,
|
|
184
|
-
name: user.name
|
|
185
|
-
} : void 0
|
|
186
|
-
}
|
|
506
|
+
}))
|
|
187
507
|
})}<\/script>`;
|
|
188
508
|
const index = template.indexOf("</body>");
|
|
189
509
|
if (index !== -1) {
|
|
190
510
|
template = template.slice(0, index) + script + template.slice(index);
|
|
191
511
|
}
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
});
|
|
195
|
-
}
|
|
196
|
-
}
|
|
197
|
-
|
|
198
|
-
const sessionUserSchema = core.t.object({
|
|
199
|
-
id: core.t.string(),
|
|
200
|
-
name: core.t.optional(core.t.string())
|
|
201
|
-
});
|
|
202
|
-
const sessionSchema = core.t.object({
|
|
203
|
-
user: core.t.optional(sessionUserSchema)
|
|
204
|
-
});
|
|
205
|
-
const envSchema = core.t.object({
|
|
206
|
-
REACT_OIDC_ISSUER: core.t.optional(core.t.string()),
|
|
207
|
-
REACT_OIDC_CLIENT_ID: core.t.optional(core.t.string()),
|
|
208
|
-
REACT_OIDC_CLIENT_SECRET: core.t.optional(core.t.string()),
|
|
209
|
-
REACT_OIDC_REDIRECT_URI: core.t.optional(core.t.string())
|
|
210
|
-
});
|
|
211
|
-
class ReactSessionProvider {
|
|
212
|
-
SSID = "ssid";
|
|
213
|
-
log = core.$logger();
|
|
214
|
-
env = core.$inject(envSchema);
|
|
215
|
-
serverProvider = core.$inject(server.ServerProvider);
|
|
216
|
-
securityProvider = core.$inject(security.SecurityProvider);
|
|
217
|
-
sessions = cache.$cache();
|
|
218
|
-
clients = [];
|
|
219
|
-
get redirectUri() {
|
|
220
|
-
return this.env.REACT_OIDC_REDIRECT_URI ?? `${this.serverProvider.hostname}/api/callback`;
|
|
221
|
-
}
|
|
222
|
-
configure = core.$hook({
|
|
223
|
-
name: "configure",
|
|
224
|
-
priority: 100,
|
|
225
|
-
handler: async () => {
|
|
226
|
-
const issuer = this.env.REACT_OIDC_ISSUER;
|
|
227
|
-
const clientId = this.env.REACT_OIDC_CLIENT_ID;
|
|
228
|
-
if (!issuer || !clientId) {
|
|
229
|
-
return;
|
|
230
|
-
}
|
|
231
|
-
const client = await openidClient.discovery(
|
|
232
|
-
new URL(issuer),
|
|
233
|
-
clientId,
|
|
234
|
-
{
|
|
235
|
-
client_secret: this.env.REACT_OIDC_CLIENT_SECRET
|
|
236
|
-
},
|
|
237
|
-
void 0,
|
|
238
|
-
{
|
|
239
|
-
execute: [openidClient.allowInsecureRequests]
|
|
240
|
-
}
|
|
241
|
-
);
|
|
242
|
-
this.clients = [client];
|
|
512
|
+
if (context.helmet) {
|
|
513
|
+
template = this.renderHelmetContext(template, context.helmet);
|
|
243
514
|
}
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
* @param sessionId
|
|
248
|
-
* @param session
|
|
249
|
-
* @protected
|
|
250
|
-
*/
|
|
251
|
-
async setSession(sessionId, session) {
|
|
252
|
-
await this.sessions.set(sessionId, session, {
|
|
253
|
-
days: 1
|
|
515
|
+
template = template.replace(this.env.REACT_SSR_OUTLET, appHtml);
|
|
516
|
+
return new Response(template, {
|
|
517
|
+
headers: { "Content-Type": "text/html" }
|
|
254
518
|
});
|
|
255
519
|
}
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
const expiresAt = session.issued_at + (session.expires_in - 10) * 1e3;
|
|
269
|
-
if (expiresAt < now) {
|
|
270
|
-
if (session.refresh_token) {
|
|
271
|
-
try {
|
|
272
|
-
const newTokens = await openidClient.refreshTokenGrant(
|
|
273
|
-
this.clients[0],
|
|
274
|
-
session.refresh_token
|
|
275
|
-
);
|
|
276
|
-
await this.setSession(sessionId, {
|
|
277
|
-
...newTokens,
|
|
278
|
-
issued_at: Date.now()
|
|
279
|
-
});
|
|
280
|
-
return newTokens;
|
|
281
|
-
} catch (e) {
|
|
282
|
-
this.log.error(e, "Failed to refresh token");
|
|
283
|
-
}
|
|
284
|
-
}
|
|
285
|
-
await this.sessions.invalidate(sessionId);
|
|
286
|
-
return;
|
|
520
|
+
renderHelmetContext(template, helmetContext) {
|
|
521
|
+
if (helmetContext.title) {
|
|
522
|
+
if (template.includes("<title>")) {
|
|
523
|
+
template = template.replace(
|
|
524
|
+
/<title>.*<\/title>/,
|
|
525
|
+
`<title>${helmetContext.title}</title>`
|
|
526
|
+
);
|
|
527
|
+
} else {
|
|
528
|
+
template = template.replace(
|
|
529
|
+
"</head>",
|
|
530
|
+
`<title>${helmetContext.title}</title></head>`
|
|
531
|
+
);
|
|
287
532
|
}
|
|
288
533
|
}
|
|
289
|
-
|
|
290
|
-
await this.sessions.invalidate(sessionId);
|
|
291
|
-
return;
|
|
292
|
-
}
|
|
293
|
-
return session;
|
|
534
|
+
return template;
|
|
294
535
|
}
|
|
295
|
-
/**
|
|
296
|
-
*
|
|
297
|
-
* @protected
|
|
298
|
-
*/
|
|
299
|
-
beforeRequest = core.$hook({
|
|
300
|
-
name: "configure:fastify",
|
|
301
|
-
priority: 100,
|
|
302
|
-
handler: async (app) => {
|
|
303
|
-
app.decorateRequest("session");
|
|
304
|
-
app.addHook("onRequest", async (req) => {
|
|
305
|
-
const sessionId = req.cookies[this.SSID];
|
|
306
|
-
if (sessionId && !isViteFile(req.url)) {
|
|
307
|
-
const session = await this.getSession(sessionId);
|
|
308
|
-
if (session) {
|
|
309
|
-
req.session = session;
|
|
310
|
-
if (session.access_token) {
|
|
311
|
-
req.headers.authorization = `Bearer ${session.access_token}`;
|
|
312
|
-
}
|
|
313
|
-
}
|
|
314
|
-
}
|
|
315
|
-
});
|
|
316
|
-
}
|
|
317
|
-
});
|
|
318
|
-
/**
|
|
319
|
-
*
|
|
320
|
-
*/
|
|
321
|
-
login = server.$route({
|
|
322
|
-
security: false,
|
|
323
|
-
url: "/login",
|
|
324
|
-
method: "GET",
|
|
325
|
-
schema: {
|
|
326
|
-
query: core.t.object({
|
|
327
|
-
redirect: core.t.optional(core.t.string())
|
|
328
|
-
})
|
|
329
|
-
},
|
|
330
|
-
handler: async ({ query }) => {
|
|
331
|
-
const client = this.clients[0];
|
|
332
|
-
const codeVerifier = openidClient.randomPKCECodeVerifier();
|
|
333
|
-
const codeChallenge = await openidClient.calculatePKCECodeChallenge(codeVerifier);
|
|
334
|
-
const scope = "openid profile email";
|
|
335
|
-
const parameters = {
|
|
336
|
-
redirect_uri: this.redirectUri,
|
|
337
|
-
scope,
|
|
338
|
-
code_challenge: codeChallenge,
|
|
339
|
-
code_challenge_method: "S256"
|
|
340
|
-
};
|
|
341
|
-
const sessionId = crypto.randomUUID();
|
|
342
|
-
await this.setSession(sessionId, {
|
|
343
|
-
authorizationCodeGrant: {
|
|
344
|
-
codeVerifier,
|
|
345
|
-
redirectUri: query.redirect ?? "/"
|
|
346
|
-
// TODO: add nonce, max_age, state
|
|
347
|
-
}
|
|
348
|
-
});
|
|
349
|
-
return new Response("", {
|
|
350
|
-
status: 302,
|
|
351
|
-
headers: {
|
|
352
|
-
"Set-Cookie": `${this.SSID}=${sessionId}; HttpOnly; Path=/; SameSite=Lax;`,
|
|
353
|
-
Location: openidClient.buildAuthorizationUrl(client, parameters).toString()
|
|
354
|
-
}
|
|
355
|
-
});
|
|
356
|
-
}
|
|
357
|
-
});
|
|
358
|
-
/**
|
|
359
|
-
*
|
|
360
|
-
*/
|
|
361
|
-
callback = server.$route({
|
|
362
|
-
security: false,
|
|
363
|
-
url: "/callback",
|
|
364
|
-
method: "GET",
|
|
365
|
-
schema: {
|
|
366
|
-
headers: core.t.record(core.t.string(), core.t.string()),
|
|
367
|
-
cookies: core.t.object({
|
|
368
|
-
ssid: core.t.string()
|
|
369
|
-
})
|
|
370
|
-
},
|
|
371
|
-
handler: async ({ cookies, url }) => {
|
|
372
|
-
const sessionId = cookies.ssid;
|
|
373
|
-
const session = await this.getSession(sessionId);
|
|
374
|
-
if (!session) {
|
|
375
|
-
throw new server.BadRequestError("Missing session");
|
|
376
|
-
}
|
|
377
|
-
if (!session.authorizationCodeGrant) {
|
|
378
|
-
throw new server.BadRequestError("Invalid session - missing code verifier");
|
|
379
|
-
}
|
|
380
|
-
const [, search] = url.split("?");
|
|
381
|
-
const tokens = await openidClient.authorizationCodeGrant(
|
|
382
|
-
this.clients[0],
|
|
383
|
-
new URL(`${this.redirectUri}?${search}`),
|
|
384
|
-
{
|
|
385
|
-
pkceCodeVerifier: session.authorizationCodeGrant.codeVerifier,
|
|
386
|
-
expectedNonce: session.authorizationCodeGrant.nonce,
|
|
387
|
-
expectedState: session.authorizationCodeGrant.state,
|
|
388
|
-
maxAge: session.authorizationCodeGrant.max_age
|
|
389
|
-
}
|
|
390
|
-
);
|
|
391
|
-
await this.setSession(sessionId, {
|
|
392
|
-
...tokens,
|
|
393
|
-
issued_at: Date.now()
|
|
394
|
-
});
|
|
395
|
-
return new Response("", {
|
|
396
|
-
status: 302,
|
|
397
|
-
headers: {
|
|
398
|
-
Location: session.authorizationCodeGrant.redirectUri ?? "/"
|
|
399
|
-
}
|
|
400
|
-
});
|
|
401
|
-
}
|
|
402
|
-
});
|
|
403
|
-
logout = server.$route({
|
|
404
|
-
security: false,
|
|
405
|
-
url: "/logout",
|
|
406
|
-
method: "GET",
|
|
407
|
-
schema: {
|
|
408
|
-
query: core.t.object({
|
|
409
|
-
redirect: core.t.optional(core.t.string())
|
|
410
|
-
}),
|
|
411
|
-
cookies: core.t.object({
|
|
412
|
-
ssid: core.t.string()
|
|
413
|
-
})
|
|
414
|
-
},
|
|
415
|
-
handler: async ({ query, cookies }, { fastify }) => {
|
|
416
|
-
const session = fastify?.req.session;
|
|
417
|
-
await this.sessions.invalidate(cookies.ssid);
|
|
418
|
-
const redirect = query.redirect ?? "/";
|
|
419
|
-
const params = new URLSearchParams();
|
|
420
|
-
params.set("post_logout_redirect_uri", redirect);
|
|
421
|
-
if (session?.id_token) {
|
|
422
|
-
params.set("id_token_hint", session.id_token);
|
|
423
|
-
}
|
|
424
|
-
return new Response("", {
|
|
425
|
-
status: 302,
|
|
426
|
-
headers: {
|
|
427
|
-
"Set-Cookie": `${this.SSID}=; HttpOnly; Path=/; SameSite=Lax;`,
|
|
428
|
-
Location: openidClient.buildEndSessionUrl(this.clients[0], params).toString()
|
|
429
|
-
}
|
|
430
|
-
});
|
|
431
|
-
}
|
|
432
|
-
});
|
|
433
|
-
session = server.$route({
|
|
434
|
-
security: false,
|
|
435
|
-
url: "/_session",
|
|
436
|
-
method: "GET",
|
|
437
|
-
schema: {
|
|
438
|
-
headers: core.t.object({
|
|
439
|
-
authorization: core.t.string()
|
|
440
|
-
}),
|
|
441
|
-
response: sessionSchema
|
|
442
|
-
},
|
|
443
|
-
handler: async ({ headers }) => {
|
|
444
|
-
try {
|
|
445
|
-
return {
|
|
446
|
-
user: await this.securityProvider.createUserFromToken(
|
|
447
|
-
headers.authorization
|
|
448
|
-
)
|
|
449
|
-
};
|
|
450
|
-
} catch (e) {
|
|
451
|
-
return {};
|
|
452
|
-
}
|
|
453
|
-
}
|
|
454
|
-
});
|
|
455
536
|
}
|
|
456
|
-
const isViteFile = (file) => {
|
|
457
|
-
const [pathname] = file.split("?");
|
|
458
|
-
if (pathname.startsWith("/docs")) {
|
|
459
|
-
return false;
|
|
460
|
-
}
|
|
461
|
-
if (pathname.match(/\.\w{2,5}$/)) {
|
|
462
|
-
return true;
|
|
463
|
-
}
|
|
464
|
-
if (pathname.startsWith("/@")) {
|
|
465
|
-
return true;
|
|
466
|
-
}
|
|
467
|
-
return false;
|
|
468
|
-
};
|
|
469
537
|
|
|
538
|
+
const envSchema = core.t.object({
|
|
539
|
+
REACT_AUTH_ENABLED: core.t.boolean({ default: false })
|
|
540
|
+
});
|
|
470
541
|
class ReactModule {
|
|
542
|
+
env = core.$inject(envSchema);
|
|
471
543
|
alepha = core.$inject(core.Alepha);
|
|
472
544
|
constructor() {
|
|
473
|
-
this.alepha.with(server.ServerModule).with(server.ServerLinksProvider).with(
|
|
545
|
+
this.alepha.with(server.ServerModule).with(server.ServerLinksProvider).with(useAuth.PageDescriptorProvider).with(ReactServerProvider);
|
|
546
|
+
if (this.env.REACT_AUTH_ENABLED) {
|
|
547
|
+
this.alepha.with(ReactAuthProvider);
|
|
548
|
+
this.alepha.with(useAuth.Auth);
|
|
549
|
+
}
|
|
474
550
|
}
|
|
475
551
|
}
|
|
476
|
-
core.autoInject(
|
|
552
|
+
core.autoInject(useAuth.$page, ReactModule);
|
|
553
|
+
core.autoInject(useAuth.$auth, ReactAuthProvider, useAuth.Auth);
|
|
477
554
|
|
|
478
|
-
exports.$
|
|
479
|
-
exports
|
|
480
|
-
exports.
|
|
481
|
-
exports.
|
|
482
|
-
exports.
|
|
483
|
-
exports.
|
|
484
|
-
exports.
|
|
485
|
-
exports.
|
|
486
|
-
exports.
|
|
487
|
-
exports.
|
|
488
|
-
exports.
|
|
489
|
-
exports.
|
|
490
|
-
exports.
|
|
491
|
-
exports.
|
|
492
|
-
exports.
|
|
493
|
-
exports.
|
|
494
|
-
exports.
|
|
555
|
+
exports.$auth = useAuth.$auth;
|
|
556
|
+
exports.$page = useAuth.$page;
|
|
557
|
+
exports.Auth = useAuth.Auth;
|
|
558
|
+
exports.Link = useAuth.Link;
|
|
559
|
+
exports.NestedView = useAuth.NestedView;
|
|
560
|
+
exports.PageDescriptorProvider = useAuth.PageDescriptorProvider;
|
|
561
|
+
exports.ReactBrowserProvider = useAuth.ReactBrowserProvider;
|
|
562
|
+
exports.RedirectionError = useAuth.RedirectionError;
|
|
563
|
+
exports.Router = useAuth.Router;
|
|
564
|
+
exports.RouterContext = useAuth.RouterContext;
|
|
565
|
+
exports.RouterHookApi = useAuth.RouterHookApi;
|
|
566
|
+
exports.RouterLayerContext = useAuth.RouterLayerContext;
|
|
567
|
+
exports.pageDescriptorKey = useAuth.pageDescriptorKey;
|
|
568
|
+
exports.useActive = useAuth.useActive;
|
|
569
|
+
exports.useAuth = useAuth.useAuth;
|
|
570
|
+
exports.useClient = useAuth.useClient;
|
|
571
|
+
exports.useInject = useAuth.useInject;
|
|
572
|
+
exports.useQueryParams = useAuth.useQueryParams;
|
|
573
|
+
exports.useRouter = useAuth.useRouter;
|
|
574
|
+
exports.useRouterEvents = useAuth.useRouterEvents;
|
|
575
|
+
exports.useRouterState = useAuth.useRouterState;
|
|
576
|
+
exports.ReactAuthProvider = ReactAuthProvider;
|
|
495
577
|
exports.ReactModule = ReactModule;
|
|
496
578
|
exports.ReactServerProvider = ReactServerProvider;
|
|
497
|
-
exports.ReactSessionProvider = ReactSessionProvider;
|
|
498
579
|
exports.envSchema = envSchema$1;
|
|
499
|
-
exports.sessionSchema = sessionSchema;
|
|
500
|
-
exports.sessionUserSchema = sessionUserSchema;
|