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