@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,8 +1,11 @@
1
1
  import type { Async, Static, TSchema } from "@alepha/core";
2
2
  import { KIND, NotImplementedError, __descriptor } from "@alepha/core";
3
3
  import type { UserAccountToken } from "@alepha/security";
4
+ import type { CookieManager } from "@alepha/server";
4
5
  import type { FC } from "react";
5
6
  import type { RouterHookApi } from "../hooks/RouterHookApi";
7
+ import {} from "../services/Router";
8
+ import type { RouterRenderHelmetContext } from "../services/Router";
6
9
 
7
10
  export const pageDescriptorKey = "PAGE";
8
11
 
@@ -18,22 +21,73 @@ export interface PageDescriptorOptions<
18
21
  TProps extends object = TPropsDefault,
19
22
  TPropsParent extends object = TPropsParentDefault,
20
23
  > {
21
- parent?: { options: PageDescriptorOptions<any, TPropsParent> };
24
+ /**
25
+ *
26
+ */
22
27
  name?: string;
28
+
29
+ /**
30
+ *
31
+ */
23
32
  path?: string;
33
+
34
+ /**
35
+ *
36
+ */
24
37
  schema?: TConfig;
25
- abstract?: boolean;
38
+
39
+ /**
40
+ * Function to call when the page is loaded.
41
+ */
26
42
  resolve?: (
27
43
  config: PageDescriptorConfigValue<TConfig> &
28
- TPropsParent & { user?: UserAccountToken },
44
+ TPropsParent & { context: PageContext },
45
+ context: PageContext,
29
46
  ) => Async<TProps>;
47
+
48
+ /**
49
+ * Component to render when the page is loaded.
50
+ */
30
51
  component?: FC<TProps & TPropsParent>;
52
+
53
+ /**
54
+ * Component to render when the page is loaded. (like .component)
55
+ */
31
56
  lazy?: () => Promise<{ default: FC<TProps & TPropsParent> }>;
57
+
58
+ /**
59
+ *
60
+ */
32
61
  children?: () => Array<{ options: PageDescriptorOptions }>;
62
+
63
+ /**
64
+ *
65
+ */
66
+ parent?: { options: PageDescriptorOptions<any, TPropsParent> };
67
+
68
+ /**
69
+ *
70
+ */
71
+ helmet?:
72
+ | RouterRenderHelmetContext
73
+ | ((props: TProps) => RouterRenderHelmetContext);
74
+
75
+ /**
76
+ *
77
+ */
33
78
  notFoundHandler?: FC<{ error: Error }>;
79
+
80
+ /**
81
+ *
82
+ */
34
83
  errorHandler?: FC<{ error: Error; url: string }>;
35
84
  }
36
85
 
86
+ export interface PageContext {
87
+ user?: UserAccountToken;
88
+ cookies?: CookieManager;
89
+ }
90
+
37
91
  export interface PageDescriptorConfigValue<
38
92
  TConfig extends PageDescriptorConfigSchema = PageDescriptorConfigSchema,
39
93
  > {
@@ -0,0 +1,7 @@
1
+ import type { HrefLike } from "../hooks/RouterHookApi";
2
+
3
+ export class RedirectionError extends Error {
4
+ constructor(public readonly page: HrefLike) {
5
+ super("Redirection");
6
+ }
7
+ }
@@ -0,0 +1,29 @@
1
+ import type { UserAccountToken } from "@alepha/security";
2
+ import { useContext } from "react";
3
+ import { RouterContext } from "../contexts/RouterContext";
4
+ import { Auth } from "../services/Auth";
5
+
6
+ export const useAuth = (): AuthHook => {
7
+ const ctx = useContext(RouterContext);
8
+ if (!ctx) {
9
+ throw new Error("useAuth must be used within a RouterContext");
10
+ }
11
+
12
+ const args = ctx.args ?? {};
13
+
14
+ return {
15
+ user: args.user,
16
+ logout: () => {
17
+ ctx.alepha.get(Auth).logout();
18
+ },
19
+ login: (provider?: string) => {
20
+ ctx.alepha.get(Auth).login();
21
+ },
22
+ };
23
+ };
24
+
25
+ export interface AuthHook {
26
+ user?: UserAccountToken;
27
+ logout: () => void;
28
+ login: (provider?: string) => void;
29
+ }
@@ -1,12 +1,12 @@
1
- import type { ClassEntry } from "@alepha/core";
1
+ import type { Class } from "@alepha/core";
2
2
  import { useContext } from "react";
3
3
  import { RouterContext } from "../contexts/RouterContext";
4
4
 
5
- export const useInject = <T extends object>(classEntry: ClassEntry<T>): T => {
5
+ export const useInject = <T extends object>(clazz: Class<T>): T => {
6
6
  const ctx = useContext(RouterContext);
7
7
  if (!ctx) {
8
8
  throw new Error("useRouter must be used within a <RouterProvider>");
9
9
  }
10
10
 
11
- return ctx.alepha.get(classEntry);
11
+ return ctx.alepha.get(clazz);
12
12
  };
@@ -2,6 +2,7 @@ import { $inject, Alepha, autoInject } from "@alepha/core";
2
2
  import { $page } from "./descriptors/$page";
3
3
  import { PageDescriptorProvider } from "./providers/PageDescriptorProvider";
4
4
  import { ReactBrowserProvider } from "./providers/ReactBrowserProvider";
5
+ import { Auth } from "./services/Auth";
5
6
 
6
7
  export * from "./index.shared";
7
8
  export * from "./providers/ReactBrowserProvider";
@@ -12,7 +13,8 @@ export class ReactModule {
12
13
  constructor() {
13
14
  this.alepha //
14
15
  .with(PageDescriptorProvider)
15
- .with(ReactBrowserProvider);
16
+ .with(ReactBrowserProvider)
17
+ .with(Auth);
16
18
  }
17
19
  }
18
20
 
@@ -1,17 +1,28 @@
1
1
  export { default as NestedView } from "./components/NestedView";
2
+ export { default as Link } from "./components/Link";
2
3
 
3
4
  export * from "./contexts/RouterContext";
4
5
  export * from "./contexts/RouterLayerContext";
5
6
 
7
+ export * from "./services/Auth";
8
+
6
9
  export * from "./descriptors/$page";
10
+ export * from "./descriptors/$auth";
7
11
 
8
- export * from "./hooks/useActive";
9
- export * from "./hooks/useClient";
12
+ export * from "./hooks/RouterHookApi";
13
+
14
+ // --- Hooks
15
+ // - core
10
16
  export * from "./hooks/useInject";
17
+ // - http
18
+ export * from "./hooks/useClient";
19
+ // - router
11
20
  export * from "./hooks/useQueryParams";
12
- export * from "./hooks/RouterHookApi";
13
21
  export * from "./hooks/useRouter";
14
22
  export * from "./hooks/useRouterEvents";
15
23
  export * from "./hooks/useRouterState";
24
+ export * from "./hooks/useActive";
25
+ // - auth
26
+ export * from "./hooks/useAuth";
16
27
 
17
28
  export * from "./services/Router";
package/src/index.ts CHANGED
@@ -1,29 +1,46 @@
1
- import { $inject, Alepha, autoInject } from "@alepha/core";
1
+ import { $inject, Alepha, type Static, autoInject, t } from "@alepha/core";
2
2
  import { ServerLinksProvider, ServerModule } from "@alepha/server";
3
+ import { $auth } from "./descriptors/$auth";
3
4
  import { $page } from "./descriptors/$page";
4
5
  import { PageDescriptorProvider } from "./providers/PageDescriptorProvider";
6
+ import { ReactAuthProvider } from "./providers/ReactAuthProvider";
5
7
  import { ReactServerProvider } from "./providers/ReactServerProvider";
6
- import { ReactSessionProvider } from "./providers/ReactSessionProvider";
8
+ import { Auth } from "./services/Auth";
7
9
  export { default as NestedView } from "./components/NestedView";
8
10
 
9
11
  export * from "./index.shared";
10
12
  export * from "./providers/PageDescriptorProvider";
11
13
  export * from "./providers/ReactBrowserProvider";
12
14
  export * from "./providers/ReactServerProvider";
13
- export * from "./providers/ReactSessionProvider";
15
+ export * from "./providers/ReactAuthProvider";
14
16
  export * from "./services/Router";
17
+ export * from "./errors/RedirectionError";
18
+
19
+ const envSchema = t.object({
20
+ REACT_AUTH_ENABLED: t.boolean({ default: false }),
21
+ });
22
+
23
+ declare module "@alepha/core" {
24
+ interface Env extends Partial<Static<typeof envSchema>> {}
25
+ }
15
26
 
16
27
  export class ReactModule {
28
+ protected readonly env = $inject(envSchema);
17
29
  protected readonly alepha = $inject(Alepha);
18
30
 
19
31
  constructor() {
20
32
  this.alepha //
21
33
  .with(ServerModule)
22
34
  .with(ServerLinksProvider)
23
- .with(ReactServerProvider)
24
- .with(ReactSessionProvider)
25
- .with(PageDescriptorProvider);
35
+ .with(PageDescriptorProvider)
36
+ .with(ReactServerProvider);
37
+
38
+ if (this.env.REACT_AUTH_ENABLED) {
39
+ this.alepha.with(ReactAuthProvider);
40
+ this.alepha.with(Auth);
41
+ }
26
42
  }
27
43
  }
28
44
 
29
45
  autoInject($page, ReactModule);
46
+ autoInject($auth, ReactAuthProvider, Auth);
@@ -0,0 +1,410 @@
1
+ import { $hook, $inject, $logger, Alepha, t } from "@alepha/core";
2
+ import {
3
+ $cookie,
4
+ $route,
5
+ BadRequestError,
6
+ type CookieManager,
7
+ FastifyCookieProvider,
8
+ } from "@alepha/server";
9
+ import {
10
+ type Configuration,
11
+ allowInsecureRequests,
12
+ authorizationCodeGrant,
13
+ buildAuthorizationUrl,
14
+ buildEndSessionUrl,
15
+ calculatePKCECodeChallenge,
16
+ discovery,
17
+ randomPKCECodeVerifier,
18
+ refreshTokenGrant,
19
+ } from "openid-client";
20
+ import { $auth } from "../descriptors/$auth";
21
+ import type { PreviousLayerData } from "../services/Router";
22
+
23
+ export class ReactAuthProvider {
24
+ protected readonly log = $logger();
25
+ protected readonly alepha = $inject(Alepha);
26
+ protected readonly fastifyCookieProvider = $inject(FastifyCookieProvider);
27
+ protected authProviders: AuthProvider[] = [];
28
+
29
+ protected readonly authorizationCode = $cookie({
30
+ name: "authorizationCode",
31
+ ttl: { minutes: 15 },
32
+ httpOnly: true,
33
+ schema: t.object({
34
+ codeVerifier: t.optional(t.string({ size: "long" })),
35
+ redirectUri: t.optional(t.string({ size: "long" })),
36
+ }),
37
+ });
38
+
39
+ protected readonly tokens = $cookie({
40
+ name: "tokens",
41
+ ttl: { days: 1 },
42
+ httpOnly: true,
43
+ compress: true,
44
+ schema: t.object({
45
+ access_token: t.optional(t.string({ size: "rich" })),
46
+ expires_in: t.optional(t.number()),
47
+ refresh_token: t.optional(t.string({ size: "rich" })),
48
+ id_token: t.optional(t.string({ size: "rich" })),
49
+ scope: t.optional(t.string()),
50
+ issued_at: t.optional(t.number()),
51
+ }),
52
+ });
53
+
54
+ protected readonly user = $cookie({
55
+ name: "user",
56
+ ttl: { days: 1 },
57
+ schema: t.object({
58
+ id: t.string(),
59
+ name: t.optional(t.string()),
60
+ email: t.optional(t.string()),
61
+ }),
62
+ });
63
+
64
+ protected readonly configure = $hook({
65
+ name: "configure",
66
+ handler: async () => {
67
+ const auths = this.alepha.getDescriptorValues($auth);
68
+ for (const auth of auths) {
69
+ const options = auth.value.options;
70
+ if (options.oidc) {
71
+ this.authProviders.push({
72
+ name: options.name ?? auth.key,
73
+ redirectUri: options.oidc.redirectUri ?? "/api/_oauth/callback",
74
+ client: await discovery(
75
+ new URL(options.oidc.issuer),
76
+ options.oidc.clientId,
77
+ {
78
+ client_secret: options.oidc.clientSecret,
79
+ },
80
+ undefined,
81
+ {
82
+ execute: [allowInsecureRequests],
83
+ },
84
+ ),
85
+ });
86
+ }
87
+ }
88
+ },
89
+ });
90
+
91
+ /**
92
+ * Configure Fastify to forward Session Access Token to Header Authorization.
93
+ */
94
+ protected readonly configureFastify = $hook({
95
+ name: "configure:fastify",
96
+ after: this.fastifyCookieProvider,
97
+ handler: async (app) => {
98
+ app.addHook("onRequest", async (req) => {
99
+ if (
100
+ req.cookies &&
101
+ !this.isViteFile(req.url) &&
102
+ !!this.authProviders.length
103
+ ) {
104
+ const tokens = await this.refresh(req.cookies);
105
+ if (tokens) {
106
+ req.headers.authorization = `Bearer ${tokens.access_token}`;
107
+ }
108
+
109
+ if (this.user.get(req.cookies) && !this.tokens.get(req.cookies)) {
110
+ this.user.del(req.cookies);
111
+ }
112
+ }
113
+ });
114
+ },
115
+ });
116
+
117
+ /**
118
+ *
119
+ * @param cookies
120
+ * @protected
121
+ */
122
+ protected async refresh(
123
+ cookies: CookieManager,
124
+ ): Promise<SessionTokens | undefined> {
125
+ const now = Date.now();
126
+ const tokens = this.tokens.get(cookies);
127
+ if (!tokens) {
128
+ return;
129
+ }
130
+
131
+ if (tokens.expires_in && tokens.issued_at) {
132
+ const expiresAt = tokens.issued_at + (tokens.expires_in - 10) * 1000;
133
+ if (expiresAt < now) {
134
+ // is expired
135
+ if (tokens.refresh_token) {
136
+ // but has refresh token
137
+ try {
138
+ const newTokens = await refreshTokenGrant(
139
+ this.authProviders[0].client,
140
+ tokens.refresh_token,
141
+ );
142
+
143
+ this.tokens.set(cookies, {
144
+ ...newTokens,
145
+ issued_at: Date.now(),
146
+ });
147
+
148
+ return newTokens;
149
+ } catch (e) {
150
+ this.log.warn(e, "Failed to refresh token -");
151
+ }
152
+ }
153
+
154
+ // session expired and no (valid) refresh token
155
+ this.tokens.del(cookies);
156
+ this.user.del(cookies);
157
+ return;
158
+ }
159
+ }
160
+
161
+ if (!tokens.issued_at && tokens.access_token) {
162
+ this.tokens.del(cookies);
163
+ this.user.del(cookies);
164
+ return;
165
+ }
166
+
167
+ return tokens;
168
+ }
169
+
170
+ /**
171
+ *
172
+ */
173
+ public readonly login = $route({
174
+ security: false,
175
+ private: true,
176
+ url: "/_oauth/login",
177
+ group: "auth",
178
+ method: "GET",
179
+ schema: {
180
+ query: t.object({
181
+ redirect: t.optional(t.string()),
182
+ provider: t.optional(t.string()),
183
+ }),
184
+ },
185
+ handler: async ({ query, cookies, url }) => {
186
+ const { client } = this.provider(query.provider);
187
+
188
+ const codeVerifier = randomPKCECodeVerifier();
189
+ const codeChallenge = await calculatePKCECodeChallenge(codeVerifier);
190
+ const scope = "openid profile email";
191
+
192
+ let redirect_uri = this.authProviders[0].redirectUri;
193
+ if (redirect_uri.startsWith("/")) {
194
+ redirect_uri = `${url.protocol}://${url.host}${redirect_uri}`;
195
+ }
196
+
197
+ const parameters: Record<string, string> = {
198
+ redirect_uri,
199
+ scope,
200
+ code_challenge: codeChallenge,
201
+ code_challenge_method: "S256",
202
+ };
203
+
204
+ this.authorizationCode.set(cookies, {
205
+ codeVerifier,
206
+ redirectUri: query.redirect ?? "/",
207
+ });
208
+
209
+ return new Response("", {
210
+ status: 302,
211
+ headers: {
212
+ Location: buildAuthorizationUrl(client, parameters).toString(),
213
+ },
214
+ });
215
+ },
216
+ });
217
+
218
+ /**
219
+ *
220
+ */
221
+ public readonly callback = $route({
222
+ security: false,
223
+ private: true,
224
+ url: "/_oauth/callback",
225
+ group: "auth",
226
+ method: "GET",
227
+ schema: {
228
+ query: t.object({
229
+ provider: t.optional(t.string()),
230
+ }),
231
+ },
232
+ handler: async ({ url, cookies, query }) => {
233
+ const { client } = this.provider(query.provider);
234
+
235
+ const authorizationCode = this.authorizationCode.get(cookies);
236
+ if (!authorizationCode) {
237
+ throw new BadRequestError("Missing code verifier");
238
+ }
239
+
240
+ const tokens = await authorizationCodeGrant(client, url, {
241
+ pkceCodeVerifier: authorizationCode.codeVerifier,
242
+ });
243
+
244
+ this.authorizationCode.del(cookies);
245
+
246
+ this.tokens.set(cookies, {
247
+ ...tokens,
248
+ issued_at: Date.now(),
249
+ });
250
+
251
+ const user = this.userFromAccessToken(tokens.access_token);
252
+ if (user) {
253
+ this.user.set(cookies, user);
254
+ }
255
+
256
+ return Response.redirect(authorizationCode.redirectUri ?? "/");
257
+ },
258
+ });
259
+
260
+ /**
261
+ *
262
+ * @param accessToken
263
+ * @protected
264
+ */
265
+ protected userFromAccessToken(accessToken: string) {
266
+ try {
267
+ const parts = accessToken.split(".");
268
+ if (parts.length !== 3) {
269
+ return;
270
+ }
271
+
272
+ const payload = parts[1];
273
+ const decoded = JSON.parse(atob(payload));
274
+ if (!decoded.sub) {
275
+ return;
276
+ }
277
+
278
+ return {
279
+ id: decoded.sub,
280
+ name: decoded.name,
281
+ email: decoded.email,
282
+ // organization
283
+ // ...
284
+ };
285
+ } catch (e) {
286
+ this.log.warn(e, "Failed to decode access token");
287
+ }
288
+ }
289
+
290
+ /**
291
+ *
292
+ */
293
+ public readonly logout = $route({
294
+ security: false,
295
+ private: true,
296
+ url: "/_oauth/logout",
297
+ group: "auth",
298
+ method: "GET",
299
+ schema: {
300
+ query: t.object({
301
+ redirect: t.optional(t.string()),
302
+ provider: t.optional(t.string()),
303
+ }),
304
+ },
305
+ handler: async ({ query, cookies }) => {
306
+ const { client } = this.provider(query.provider);
307
+ const tokens = this.tokens.get(cookies);
308
+ const idToken = tokens?.id_token;
309
+
310
+ const redirect = query.redirect ?? "/";
311
+ const params = new URLSearchParams();
312
+
313
+ params.set("post_logout_redirect_uri", redirect);
314
+ if (idToken) {
315
+ params.set("id_token_hint", idToken);
316
+ }
317
+
318
+ this.tokens.del(cookies);
319
+ this.user.del(cookies);
320
+
321
+ return Response.redirect(buildEndSessionUrl(client, params).toString());
322
+ },
323
+ });
324
+
325
+ /**
326
+ *
327
+ * @param name
328
+ * @protected
329
+ */
330
+ protected provider(name?: string) {
331
+ if (!name) {
332
+ const client = this.authProviders[0];
333
+ if (!client) {
334
+ throw new BadRequestError("Client name is required");
335
+ }
336
+ return client;
337
+ }
338
+
339
+ const authProvider = this.authProviders.find(
340
+ (provider) => provider.name === name,
341
+ );
342
+ if (!authProvider) {
343
+ throw new BadRequestError(`Client ${name} not found`);
344
+ }
345
+
346
+ return authProvider;
347
+ }
348
+
349
+ /**
350
+ *
351
+ * @param file
352
+ * @protected
353
+ */
354
+ protected isViteFile(file: string) {
355
+ const [pathname] = file.split("?");
356
+
357
+ // swagger
358
+ if (pathname.startsWith("/docs")) {
359
+ return false;
360
+ }
361
+
362
+ // static assets
363
+ if (pathname.match(/\.\w{2,5}$/)) {
364
+ return true;
365
+ }
366
+
367
+ // vite internal files
368
+ if (pathname.startsWith("/@")) {
369
+ return true;
370
+ }
371
+
372
+ // our backend files
373
+ return false;
374
+ }
375
+ }
376
+
377
+ export interface SessionTokens {
378
+ access_token?: string;
379
+ expires_in?: number;
380
+ refresh_token?: string;
381
+ id_token?: string;
382
+ scope?: string;
383
+ issued_at?: number;
384
+ }
385
+
386
+ export interface SessionAuthorizationCode {
387
+ codeVerifier?: string;
388
+ redirectUri?: string;
389
+ nonce?: string;
390
+ max_age?: number;
391
+ state?: string;
392
+ }
393
+
394
+ export interface AuthProvider {
395
+ name: string;
396
+ redirectUri: string;
397
+ client: Configuration;
398
+ }
399
+
400
+ export interface ReactUser {
401
+ id: string;
402
+ name?: string;
403
+ email?: string;
404
+ }
405
+
406
+ export interface ReactHydrationState {
407
+ user?: ReactUser;
408
+ auth?: "server" | "client";
409
+ layers?: PreviousLayerData[];
410
+ }