@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.
@@ -1,363 +0,0 @@
1
- import crypto from "node:crypto";
2
- import { $cache } from "@alepha/cache";
3
- import { $hook, $inject, $logger, type Static, t } from "@alepha/core";
4
- import { SecurityProvider } from "@alepha/security";
5
- import { $route, BadRequestError, ServerProvider } from "@alepha/server";
6
- import {
7
- type Configuration,
8
- allowInsecureRequests,
9
- authorizationCodeGrant,
10
- buildAuthorizationUrl,
11
- buildEndSessionUrl,
12
- calculatePKCECodeChallenge,
13
- discovery,
14
- randomPKCECodeVerifier,
15
- refreshTokenGrant,
16
- } from "openid-client";
17
-
18
- export const sessionUserSchema = t.object({
19
- id: t.string(),
20
- name: t.optional(t.string()),
21
- });
22
-
23
- export const sessionSchema = t.object({
24
- user: t.optional(sessionUserSchema),
25
- });
26
-
27
- export type Session = Static<typeof sessionSchema>;
28
-
29
- const envSchema = t.object({
30
- REACT_OIDC_ISSUER: t.optional(t.string()),
31
- REACT_OIDC_CLIENT_ID: t.optional(t.string()),
32
- REACT_OIDC_CLIENT_SECRET: t.optional(t.string()),
33
- REACT_OIDC_REDIRECT_URI: t.optional(t.string()),
34
- });
35
-
36
- declare module "fastify" {
37
- interface FastifyRequest {
38
- session?: ReactServerSession;
39
- }
40
- }
41
-
42
- declare module "@alepha/core" {
43
- interface Env extends Partial<Static<typeof envSchema>> {}
44
- }
45
-
46
- export class ReactSessionProvider {
47
- protected readonly SSID = "ssid";
48
- protected readonly log = $logger();
49
- protected readonly env = $inject(envSchema);
50
- protected readonly serverProvider = $inject(ServerProvider);
51
- protected readonly securityProvider = $inject(SecurityProvider);
52
- protected readonly sessions = $cache<ReactServerSession>();
53
- protected clients: Configuration[] = [];
54
-
55
- public get redirectUri() {
56
- return (
57
- this.env.REACT_OIDC_REDIRECT_URI ??
58
- `${this.serverProvider.hostname}/api/callback`
59
- );
60
- }
61
-
62
- protected readonly configure = $hook({
63
- name: "configure",
64
- priority: 100,
65
- handler: async () => {
66
- const issuer = this.env.REACT_OIDC_ISSUER;
67
- const clientId = this.env.REACT_OIDC_CLIENT_ID;
68
- if (!issuer || !clientId) {
69
- return;
70
- }
71
-
72
- const client = await discovery(
73
- new URL(issuer),
74
- clientId,
75
- {
76
- client_secret: this.env.REACT_OIDC_CLIENT_SECRET,
77
- },
78
- undefined,
79
- {
80
- execute: [allowInsecureRequests],
81
- },
82
- );
83
-
84
- this.clients = [client];
85
- },
86
- });
87
-
88
- /**
89
- *
90
- * @param sessionId
91
- * @param session
92
- * @protected
93
- */
94
- protected async setSession(sessionId: string, session: ReactServerSession) {
95
- await this.sessions.set(sessionId, session, {
96
- days: 1,
97
- });
98
- }
99
-
100
- /**
101
- *
102
- * @param sessionId
103
- * @protected
104
- */
105
- protected async getSession(
106
- sessionId: string,
107
- ): Promise<ReactServerSession | undefined> {
108
- const session = await this.sessions.get(sessionId);
109
- if (!session) {
110
- return;
111
- }
112
-
113
- const now = Date.now();
114
-
115
- if (session.expires_in && session.issued_at) {
116
- const expiresAt = session.issued_at + (session.expires_in - 10) * 1000;
117
- if (expiresAt < now) {
118
- if (session.refresh_token) {
119
- try {
120
- const newTokens = await refreshTokenGrant(
121
- this.clients[0],
122
- session.refresh_token,
123
- );
124
-
125
- await this.setSession(sessionId, {
126
- ...newTokens,
127
- issued_at: Date.now(),
128
- });
129
-
130
- return newTokens;
131
- } catch (e) {
132
- this.log.error(e, "Failed to refresh token");
133
- }
134
- }
135
- await this.sessions.invalidate(sessionId);
136
- return;
137
- }
138
- }
139
-
140
- if (!session.issued_at && session.access_token) {
141
- await this.sessions.invalidate(sessionId);
142
- return;
143
- }
144
-
145
- return session;
146
- }
147
-
148
- /**
149
- *
150
- * @protected
151
- */
152
- protected readonly beforeRequest = $hook({
153
- name: "configure:fastify",
154
- priority: 100,
155
- handler: async (app) => {
156
- app.decorateRequest("session");
157
- app.addHook("onRequest", async (req) => {
158
- const sessionId = (req as any).cookies[this.SSID];
159
- if (sessionId && !isViteFile(req.url)) {
160
- const session = await this.getSession(sessionId);
161
- if (session) {
162
- req.session = session;
163
- if (session.access_token) {
164
- req.headers.authorization = `Bearer ${session.access_token}`;
165
- }
166
- }
167
- }
168
- });
169
- },
170
- });
171
-
172
- /**
173
- *
174
- */
175
- public readonly login = $route({
176
- security: false,
177
- url: "/login",
178
- method: "GET",
179
- schema: {
180
- query: t.object({
181
- redirect: t.optional(t.string()),
182
- }),
183
- },
184
- handler: async ({ query }) => {
185
- const client = this.clients[0];
186
-
187
- const codeVerifier = randomPKCECodeVerifier();
188
- const codeChallenge = await calculatePKCECodeChallenge(codeVerifier);
189
- const scope = "openid profile email";
190
-
191
- const parameters: Record<string, string> = {
192
- redirect_uri: this.redirectUri,
193
- scope,
194
- code_challenge: codeChallenge,
195
- code_challenge_method: "S256",
196
- };
197
-
198
- const sessionId = crypto.randomUUID();
199
- await this.setSession(sessionId, {
200
- authorizationCodeGrant: {
201
- codeVerifier,
202
- redirectUri: query.redirect ?? "/",
203
- // TODO: add nonce, max_age, state
204
- },
205
- });
206
-
207
- return new Response("", {
208
- status: 302,
209
- headers: {
210
- "Set-Cookie": `${this.SSID}=${sessionId}; HttpOnly; Path=/; SameSite=Lax;`,
211
- Location: buildAuthorizationUrl(client, parameters).toString(),
212
- },
213
- });
214
- },
215
- });
216
-
217
- /**
218
- *
219
- */
220
- public readonly callback = $route({
221
- security: false,
222
- url: "/callback",
223
- method: "GET",
224
- schema: {
225
- headers: t.record(t.string(), t.string()),
226
- cookies: t.object({
227
- ssid: t.string(),
228
- }),
229
- },
230
- handler: async ({ cookies, url }) => {
231
- const sessionId = cookies.ssid;
232
- const session = await this.getSession(sessionId);
233
- if (!session) {
234
- throw new BadRequestError("Missing session");
235
- }
236
-
237
- if (!session.authorizationCodeGrant) {
238
- throw new BadRequestError("Invalid session - missing code verifier");
239
- }
240
-
241
- const [, search] = url.split("?");
242
- const tokens = await authorizationCodeGrant(
243
- this.clients[0],
244
- new URL(`${this.redirectUri}?${search}`),
245
- {
246
- pkceCodeVerifier: session.authorizationCodeGrant.codeVerifier,
247
- expectedNonce: session.authorizationCodeGrant.nonce,
248
- expectedState: session.authorizationCodeGrant.state,
249
- maxAge: session.authorizationCodeGrant.max_age,
250
- },
251
- );
252
-
253
- await this.setSession(sessionId, {
254
- ...tokens,
255
- issued_at: Date.now(),
256
- });
257
-
258
- return new Response("", {
259
- status: 302,
260
- headers: {
261
- Location: session.authorizationCodeGrant.redirectUri ?? "/",
262
- },
263
- });
264
- },
265
- });
266
-
267
- public readonly logout = $route({
268
- security: false,
269
- url: "/logout",
270
- method: "GET",
271
- schema: {
272
- query: t.object({
273
- redirect: t.optional(t.string()),
274
- }),
275
- cookies: t.object({
276
- ssid: t.string(),
277
- }),
278
- },
279
- handler: async ({ query, cookies }, { fastify }: any) => {
280
- const session = fastify?.req.session;
281
-
282
- await this.sessions.invalidate(cookies.ssid);
283
-
284
- const redirect = query.redirect ?? "/";
285
-
286
- const params = new URLSearchParams();
287
- params.set("post_logout_redirect_uri", redirect);
288
- if (session?.id_token) {
289
- params.set("id_token_hint", session.id_token);
290
- }
291
-
292
- return new Response("", {
293
- status: 302,
294
- headers: {
295
- "Set-Cookie": `${this.SSID}=; HttpOnly; Path=/; SameSite=Lax;`,
296
- Location: buildEndSessionUrl(this.clients[0], params).toString(),
297
- },
298
- });
299
- },
300
- });
301
-
302
- public readonly session = $route({
303
- security: false,
304
- url: "/_session",
305
- method: "GET",
306
- schema: {
307
- headers: t.object({
308
- authorization: t.string(),
309
- }),
310
- response: sessionSchema,
311
- },
312
- handler: async ({ headers }) => {
313
- try {
314
- return {
315
- user: await this.securityProvider.createUserFromToken(
316
- headers.authorization,
317
- ),
318
- };
319
- } catch (e) {
320
- return {};
321
- }
322
- },
323
- });
324
- }
325
-
326
- export interface ReactServerSession {
327
- access_token?: string;
328
- expires_in?: number;
329
- refresh_token?: string;
330
- id_token?: string;
331
- scope?: string;
332
- issued_at?: number;
333
-
334
- authorizationCodeGrant?: {
335
- codeVerifier: string;
336
- redirectUri: string;
337
- nonce?: string;
338
- max_age?: number;
339
- state?: string;
340
- };
341
- }
342
-
343
- const isViteFile = (file: string) => {
344
- const [pathname] = file.split("?");
345
-
346
- // swagger
347
- if (pathname.startsWith("/docs")) {
348
- return false;
349
- }
350
-
351
- // static assets
352
- if (pathname.match(/\.\w{2,5}$/)) {
353
- return true;
354
- }
355
-
356
- // vite internal files
357
- if (pathname.startsWith("/@")) {
358
- return true;
359
- }
360
-
361
- // our backend files
362
- return false;
363
- };