@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.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,45 +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
|
-
|
|
349
|
+
...route2,
|
|
350
|
+
url: "*"
|
|
54
351
|
});
|
|
55
352
|
return;
|
|
56
353
|
}
|
|
57
|
-
const maybe = [
|
|
58
|
-
node_path.join(process.cwd(), this.env.REACT_SERVER_DIST),
|
|
59
|
-
node_path.join(process.cwd(), "..", this.env.REACT_SERVER_DIST),
|
|
60
|
-
node_path.join(process.cwd(), "dist", this.env.REACT_SERVER_DIST)
|
|
61
|
-
];
|
|
62
354
|
let root = "";
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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
|
+
}
|
|
67
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));
|
|
68
371
|
}
|
|
69
|
-
|
|
70
|
-
this.
|
|
71
|
-
|
|
72
|
-
}
|
|
73
|
-
await this.server.serve(this.createStaticHandler(root));
|
|
74
|
-
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
|
+
);
|
|
75
375
|
const route = this.createHandler(async () => template);
|
|
76
376
|
await this.server.route(route);
|
|
77
377
|
await this.server.route({
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
handler: route.handler
|
|
378
|
+
...route,
|
|
379
|
+
url: "*"
|
|
81
380
|
});
|
|
82
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
|
+
}
|
|
83
402
|
/**
|
|
84
403
|
*
|
|
85
404
|
* @param root
|
|
@@ -104,22 +423,26 @@ class ReactServerProvider {
|
|
|
104
423
|
*/
|
|
105
424
|
createHandler(templateLoader) {
|
|
106
425
|
return {
|
|
426
|
+
method: "GET",
|
|
107
427
|
url: "/",
|
|
108
|
-
handler: async (
|
|
428
|
+
handler: async (ctx) => {
|
|
109
429
|
const template = await templateLoader();
|
|
110
430
|
if (!template) {
|
|
111
431
|
return new Response("Not found", { status: 404 });
|
|
112
432
|
}
|
|
113
|
-
const response = this.notFoundHandler(url);
|
|
433
|
+
const response = this.notFoundHandler(ctx.url);
|
|
114
434
|
if (response) {
|
|
115
435
|
return response;
|
|
116
436
|
}
|
|
117
|
-
return await this.ssr(url, template,
|
|
437
|
+
return await this.ssr(ctx.url, template, {
|
|
438
|
+
user: ctx.user,
|
|
439
|
+
cookies: ctx.cookies
|
|
440
|
+
});
|
|
118
441
|
}
|
|
119
442
|
};
|
|
120
443
|
}
|
|
121
444
|
processDescriptors() {
|
|
122
|
-
const pages = this.alepha.getDescriptorValues(
|
|
445
|
+
const pages = this.alepha.getDescriptorValues(useAuth.$page);
|
|
123
446
|
for (const { key, instance, value } of pages) {
|
|
124
447
|
instance[key].render = async (options = {}) => {
|
|
125
448
|
const name = value.options.name ?? key;
|
|
@@ -135,7 +458,8 @@ class ReactServerProvider {
|
|
|
135
458
|
this.router.root({
|
|
136
459
|
layers,
|
|
137
460
|
pathname: "",
|
|
138
|
-
search: ""
|
|
461
|
+
search: "",
|
|
462
|
+
context: {}
|
|
139
463
|
})
|
|
140
464
|
);
|
|
141
465
|
};
|
|
@@ -147,7 +471,7 @@ class ReactServerProvider {
|
|
|
147
471
|
* @protected
|
|
148
472
|
*/
|
|
149
473
|
notFoundHandler(url) {
|
|
150
|
-
if (url.match(/\.\w+$/)) {
|
|
474
|
+
if (url.pathname.match(/\.\w+$/)) {
|
|
151
475
|
return new Response("Not found", { status: 404 });
|
|
152
476
|
}
|
|
153
477
|
}
|
|
@@ -155,12 +479,15 @@ class ReactServerProvider {
|
|
|
155
479
|
*
|
|
156
480
|
* @param url
|
|
157
481
|
* @param template
|
|
158
|
-
* @param
|
|
482
|
+
* @param page
|
|
159
483
|
*/
|
|
160
|
-
async ssr(url, template = this.env.REACT_SSR_OUTLET,
|
|
161
|
-
const { element, layers, redirect } = await this.router.render(
|
|
162
|
-
|
|
163
|
-
|
|
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
|
+
);
|
|
164
491
|
if (redirect) {
|
|
165
492
|
return new Response("", {
|
|
166
493
|
status: 302,
|
|
@@ -176,324 +503,77 @@ class ReactServerProvider {
|
|
|
176
503
|
index: void 0,
|
|
177
504
|
path: void 0,
|
|
178
505
|
element: void 0
|
|
179
|
-
}))
|
|
180
|
-
session: {
|
|
181
|
-
user: user ? {
|
|
182
|
-
id: user.id,
|
|
183
|
-
name: user.name
|
|
184
|
-
} : void 0
|
|
185
|
-
}
|
|
506
|
+
}))
|
|
186
507
|
})}<\/script>`;
|
|
187
508
|
const index = template.indexOf("</body>");
|
|
188
509
|
if (index !== -1) {
|
|
189
510
|
template = template.slice(0, index) + script + template.slice(index);
|
|
190
511
|
}
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
});
|
|
194
|
-
}
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
const sessionUserSchema = core.t.object({
|
|
198
|
-
id: core.t.string(),
|
|
199
|
-
name: core.t.optional(core.t.string())
|
|
200
|
-
});
|
|
201
|
-
const sessionSchema = core.t.object({
|
|
202
|
-
user: core.t.optional(sessionUserSchema)
|
|
203
|
-
});
|
|
204
|
-
const envSchema = core.t.object({
|
|
205
|
-
REACT_OIDC_ISSUER: core.t.optional(core.t.string()),
|
|
206
|
-
REACT_OIDC_CLIENT_ID: core.t.optional(core.t.string()),
|
|
207
|
-
REACT_OIDC_CLIENT_SECRET: core.t.optional(core.t.string()),
|
|
208
|
-
REACT_OIDC_REDIRECT_URI: core.t.optional(core.t.string())
|
|
209
|
-
});
|
|
210
|
-
class ReactSessionProvider {
|
|
211
|
-
SSID = "ssid";
|
|
212
|
-
log = core.$logger();
|
|
213
|
-
env = core.$inject(envSchema);
|
|
214
|
-
serverProvider = core.$inject(server.ServerProvider);
|
|
215
|
-
securityProvider = core.$inject(security.SecurityProvider);
|
|
216
|
-
sessions = cache.$cache();
|
|
217
|
-
clients = [];
|
|
218
|
-
get redirectUri() {
|
|
219
|
-
return this.env.REACT_OIDC_REDIRECT_URI ?? `${this.serverProvider.hostname}/api/callback`;
|
|
220
|
-
}
|
|
221
|
-
configure = core.$hook({
|
|
222
|
-
name: "configure",
|
|
223
|
-
priority: 100,
|
|
224
|
-
handler: async () => {
|
|
225
|
-
const issuer = this.env.REACT_OIDC_ISSUER;
|
|
226
|
-
const clientId = this.env.REACT_OIDC_CLIENT_ID;
|
|
227
|
-
if (!issuer || !clientId) {
|
|
228
|
-
return;
|
|
229
|
-
}
|
|
230
|
-
const client = await openidClient.discovery(
|
|
231
|
-
new URL(issuer),
|
|
232
|
-
clientId,
|
|
233
|
-
{
|
|
234
|
-
client_secret: this.env.REACT_OIDC_CLIENT_SECRET
|
|
235
|
-
},
|
|
236
|
-
void 0,
|
|
237
|
-
{
|
|
238
|
-
execute: [openidClient.allowInsecureRequests]
|
|
239
|
-
}
|
|
240
|
-
);
|
|
241
|
-
this.clients = [client];
|
|
512
|
+
if (context.helmet) {
|
|
513
|
+
template = this.renderHelmetContext(template, context.helmet);
|
|
242
514
|
}
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
* @param sessionId
|
|
247
|
-
* @param session
|
|
248
|
-
* @protected
|
|
249
|
-
*/
|
|
250
|
-
async setSession(sessionId, session) {
|
|
251
|
-
await this.sessions.set(sessionId, session, {
|
|
252
|
-
days: 1
|
|
515
|
+
template = template.replace(this.env.REACT_SSR_OUTLET, appHtml);
|
|
516
|
+
return new Response(template, {
|
|
517
|
+
headers: { "Content-Type": "text/html" }
|
|
253
518
|
});
|
|
254
519
|
}
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
const expiresAt = session.issued_at + (session.expires_in - 10) * 1e3;
|
|
268
|
-
if (expiresAt < now) {
|
|
269
|
-
if (session.refresh_token) {
|
|
270
|
-
try {
|
|
271
|
-
const newTokens = await openidClient.refreshTokenGrant(
|
|
272
|
-
this.clients[0],
|
|
273
|
-
session.refresh_token
|
|
274
|
-
);
|
|
275
|
-
await this.setSession(sessionId, {
|
|
276
|
-
...newTokens,
|
|
277
|
-
issued_at: Date.now()
|
|
278
|
-
});
|
|
279
|
-
return newTokens;
|
|
280
|
-
} catch (e) {
|
|
281
|
-
this.log.error(e, "Failed to refresh token");
|
|
282
|
-
}
|
|
283
|
-
}
|
|
284
|
-
await this.sessions.invalidate(sessionId);
|
|
285
|
-
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
|
+
);
|
|
286
532
|
}
|
|
287
533
|
}
|
|
288
|
-
|
|
289
|
-
await this.sessions.invalidate(sessionId);
|
|
290
|
-
return;
|
|
291
|
-
}
|
|
292
|
-
return session;
|
|
534
|
+
return template;
|
|
293
535
|
}
|
|
294
|
-
/**
|
|
295
|
-
*
|
|
296
|
-
* @protected
|
|
297
|
-
*/
|
|
298
|
-
beforeRequest = core.$hook({
|
|
299
|
-
name: "configure:fastify",
|
|
300
|
-
priority: 100,
|
|
301
|
-
handler: async (app) => {
|
|
302
|
-
app.decorateRequest("session");
|
|
303
|
-
app.addHook("onRequest", async (req) => {
|
|
304
|
-
const sessionId = req.cookies[this.SSID];
|
|
305
|
-
if (sessionId && !isViteFile(req.url)) {
|
|
306
|
-
const session = await this.getSession(sessionId);
|
|
307
|
-
if (session) {
|
|
308
|
-
req.session = session;
|
|
309
|
-
if (session.access_token) {
|
|
310
|
-
req.headers.authorization = `Bearer ${session.access_token}`;
|
|
311
|
-
}
|
|
312
|
-
}
|
|
313
|
-
}
|
|
314
|
-
});
|
|
315
|
-
}
|
|
316
|
-
});
|
|
317
|
-
/**
|
|
318
|
-
*
|
|
319
|
-
*/
|
|
320
|
-
login = server.$route({
|
|
321
|
-
security: false,
|
|
322
|
-
url: "/login",
|
|
323
|
-
method: "GET",
|
|
324
|
-
schema: {
|
|
325
|
-
query: core.t.object({
|
|
326
|
-
redirect: core.t.optional(core.t.string())
|
|
327
|
-
})
|
|
328
|
-
},
|
|
329
|
-
handler: async ({ query }) => {
|
|
330
|
-
const client = this.clients[0];
|
|
331
|
-
const codeVerifier = openidClient.randomPKCECodeVerifier();
|
|
332
|
-
const codeChallenge = await openidClient.calculatePKCECodeChallenge(codeVerifier);
|
|
333
|
-
const scope = "openid profile email";
|
|
334
|
-
const parameters = {
|
|
335
|
-
redirect_uri: this.redirectUri,
|
|
336
|
-
scope,
|
|
337
|
-
code_challenge: codeChallenge,
|
|
338
|
-
code_challenge_method: "S256"
|
|
339
|
-
};
|
|
340
|
-
const sessionId = crypto.randomUUID();
|
|
341
|
-
await this.setSession(sessionId, {
|
|
342
|
-
authorizationCodeGrant: {
|
|
343
|
-
codeVerifier,
|
|
344
|
-
redirectUri: query.redirect ?? "/"
|
|
345
|
-
// TODO: add nonce, max_age, state
|
|
346
|
-
}
|
|
347
|
-
});
|
|
348
|
-
return new Response("", {
|
|
349
|
-
status: 302,
|
|
350
|
-
headers: {
|
|
351
|
-
"Set-Cookie": `${this.SSID}=${sessionId}; HttpOnly; Path=/; SameSite=Lax;`,
|
|
352
|
-
Location: openidClient.buildAuthorizationUrl(client, parameters).toString()
|
|
353
|
-
}
|
|
354
|
-
});
|
|
355
|
-
}
|
|
356
|
-
});
|
|
357
|
-
/**
|
|
358
|
-
*
|
|
359
|
-
*/
|
|
360
|
-
callback = server.$route({
|
|
361
|
-
security: false,
|
|
362
|
-
url: "/callback",
|
|
363
|
-
method: "GET",
|
|
364
|
-
schema: {
|
|
365
|
-
headers: core.t.record(core.t.string(), core.t.string()),
|
|
366
|
-
cookies: core.t.object({
|
|
367
|
-
ssid: core.t.string()
|
|
368
|
-
})
|
|
369
|
-
},
|
|
370
|
-
handler: async ({ cookies, url }) => {
|
|
371
|
-
const sessionId = cookies.ssid;
|
|
372
|
-
const session = await this.getSession(sessionId);
|
|
373
|
-
if (!session) {
|
|
374
|
-
throw new server.BadRequestError("Missing session");
|
|
375
|
-
}
|
|
376
|
-
if (!session.authorizationCodeGrant) {
|
|
377
|
-
throw new server.BadRequestError("Invalid session - missing code verifier");
|
|
378
|
-
}
|
|
379
|
-
const [, search] = url.split("?");
|
|
380
|
-
const tokens = await openidClient.authorizationCodeGrant(
|
|
381
|
-
this.clients[0],
|
|
382
|
-
new URL(`${this.redirectUri}?${search}`),
|
|
383
|
-
{
|
|
384
|
-
pkceCodeVerifier: session.authorizationCodeGrant.codeVerifier,
|
|
385
|
-
expectedNonce: session.authorizationCodeGrant.nonce,
|
|
386
|
-
expectedState: session.authorizationCodeGrant.state,
|
|
387
|
-
maxAge: session.authorizationCodeGrant.max_age
|
|
388
|
-
}
|
|
389
|
-
);
|
|
390
|
-
await this.setSession(sessionId, {
|
|
391
|
-
...tokens,
|
|
392
|
-
issued_at: Date.now()
|
|
393
|
-
});
|
|
394
|
-
return new Response("", {
|
|
395
|
-
status: 302,
|
|
396
|
-
headers: {
|
|
397
|
-
Location: session.authorizationCodeGrant.redirectUri ?? "/"
|
|
398
|
-
}
|
|
399
|
-
});
|
|
400
|
-
}
|
|
401
|
-
});
|
|
402
|
-
logout = server.$route({
|
|
403
|
-
security: false,
|
|
404
|
-
url: "/logout",
|
|
405
|
-
method: "GET",
|
|
406
|
-
schema: {
|
|
407
|
-
query: core.t.object({
|
|
408
|
-
redirect: core.t.optional(core.t.string())
|
|
409
|
-
}),
|
|
410
|
-
cookies: core.t.object({
|
|
411
|
-
ssid: core.t.string()
|
|
412
|
-
})
|
|
413
|
-
},
|
|
414
|
-
handler: async ({ query, cookies }, { fastify }) => {
|
|
415
|
-
const session = fastify?.req.session;
|
|
416
|
-
await this.sessions.invalidate(cookies.ssid);
|
|
417
|
-
const redirect = query.redirect ?? "/";
|
|
418
|
-
const params = new URLSearchParams();
|
|
419
|
-
params.set("post_logout_redirect_uri", redirect);
|
|
420
|
-
if (session?.id_token) {
|
|
421
|
-
params.set("id_token_hint", session.id_token);
|
|
422
|
-
}
|
|
423
|
-
return new Response("", {
|
|
424
|
-
status: 302,
|
|
425
|
-
headers: {
|
|
426
|
-
"Set-Cookie": `${this.SSID}=; HttpOnly; Path=/; SameSite=Lax;`,
|
|
427
|
-
Location: openidClient.buildEndSessionUrl(this.clients[0], params).toString()
|
|
428
|
-
}
|
|
429
|
-
});
|
|
430
|
-
}
|
|
431
|
-
});
|
|
432
|
-
session = server.$route({
|
|
433
|
-
security: false,
|
|
434
|
-
url: "/_session",
|
|
435
|
-
method: "GET",
|
|
436
|
-
schema: {
|
|
437
|
-
headers: core.t.object({
|
|
438
|
-
authorization: core.t.string()
|
|
439
|
-
}),
|
|
440
|
-
response: sessionSchema
|
|
441
|
-
},
|
|
442
|
-
handler: async ({ headers }) => {
|
|
443
|
-
try {
|
|
444
|
-
return {
|
|
445
|
-
user: await this.securityProvider.createUserFromToken(
|
|
446
|
-
headers.authorization
|
|
447
|
-
)
|
|
448
|
-
};
|
|
449
|
-
} catch (e) {
|
|
450
|
-
return {};
|
|
451
|
-
}
|
|
452
|
-
}
|
|
453
|
-
});
|
|
454
536
|
}
|
|
455
|
-
const isViteFile = (file) => {
|
|
456
|
-
const [pathname] = file.split("?");
|
|
457
|
-
if (pathname.startsWith("/docs")) {
|
|
458
|
-
return false;
|
|
459
|
-
}
|
|
460
|
-
if (pathname.match(/\.\w{2,5}$/)) {
|
|
461
|
-
return true;
|
|
462
|
-
}
|
|
463
|
-
if (pathname.startsWith("/@")) {
|
|
464
|
-
return true;
|
|
465
|
-
}
|
|
466
|
-
return false;
|
|
467
|
-
};
|
|
468
537
|
|
|
538
|
+
const envSchema = core.t.object({
|
|
539
|
+
REACT_AUTH_ENABLED: core.t.boolean({ default: false })
|
|
540
|
+
});
|
|
469
541
|
class ReactModule {
|
|
542
|
+
env = core.$inject(envSchema);
|
|
470
543
|
alepha = core.$inject(core.Alepha);
|
|
471
544
|
constructor() {
|
|
472
|
-
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
|
+
}
|
|
473
550
|
}
|
|
474
551
|
}
|
|
475
|
-
core.autoInject(
|
|
552
|
+
core.autoInject(useAuth.$page, ReactModule);
|
|
553
|
+
core.autoInject(useAuth.$auth, ReactAuthProvider, useAuth.Auth);
|
|
476
554
|
|
|
477
|
-
exports.$
|
|
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.
|
|
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;
|
|
494
577
|
exports.ReactModule = ReactModule;
|
|
495
578
|
exports.ReactServerProvider = ReactServerProvider;
|
|
496
|
-
exports.ReactSessionProvider = ReactSessionProvider;
|
|
497
579
|
exports.envSchema = envSchema$1;
|
|
498
|
-
exports.sessionSchema = sessionSchema;
|
|
499
|
-
exports.sessionUserSchema = sessionUserSchema;
|