zudoku 0.82.2 → 0.82.4

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.
Files changed (39) hide show
  1. package/dist/cli/cli.js +283 -225
  2. package/dist/cli/worker.js +23 -0
  3. package/dist/declarations/app/wrapProtectedRoutes.d.ts +5 -0
  4. package/dist/declarations/config/config.d.ts +3 -0
  5. package/dist/declarations/config/plugin-versions.d.ts +5 -0
  6. package/dist/declarations/config/validators/ZudokuConfig.d.ts +38 -0
  7. package/dist/declarations/lib/authentication/providers/entra.d.ts +13 -0
  8. package/dist/declarations/lib/authentication/providers/openid.d.ts +4 -1
  9. package/dist/declarations/lib/authentication/providers/util.d.ts +4 -0
  10. package/dist/declarations/lib/plugins/openapi/McpClientLogos.d.ts +4 -0
  11. package/dist/declarations/lib/plugins/openapi/mcp-configs.d.ts +3 -0
  12. package/dist/declarations/vite/package-root.d.ts +1 -0
  13. package/dist/flat-config.d.ts +17 -0
  14. package/docs/components/landing-page.mdx +1 -1
  15. package/docs/configuration/authentication-azure-ad.md +17 -15
  16. package/docs/configuration/authentication.md +22 -2
  17. package/docs/guides/mcp-servers.md +26 -0
  18. package/package.json +21 -17
  19. package/src/app/entry.server.tsx +9 -5
  20. package/src/app/wrapProtectedRoutes.ts +25 -0
  21. package/src/config/config.ts +4 -0
  22. package/src/config/loader.ts +15 -0
  23. package/src/config/plugin-versions.ts +38 -0
  24. package/src/config/validators/ZudokuConfig.ts +19 -0
  25. package/src/lib/auth/issuer.ts +7 -1
  26. package/src/lib/authentication/providers/entra.tsx +97 -0
  27. package/src/lib/authentication/providers/openid.tsx +29 -13
  28. package/src/lib/authentication/providers/util.ts +7 -0
  29. package/src/lib/components/navigation/NavigationCategory.tsx +3 -2
  30. package/src/lib/components/navigation/StackRows.tsx +1 -1
  31. package/src/lib/plugins/openapi/MCPEndpoint.tsx +347 -228
  32. package/src/lib/plugins/openapi/McpClientLogos.tsx +72 -0
  33. package/src/lib/plugins/openapi/mcp-configs.ts +55 -0
  34. package/src/vite/api/SchemaManager.ts +11 -25
  35. package/src/vite/plugin-mdx.ts +3 -0
  36. package/src/vite/prerender/prerender.ts +28 -3
  37. package/src/vite/prerender/utils.ts +33 -0
  38. package/src/vite/prerender/worker.ts +8 -1
  39. package/src/zuplo/enrich-with-zuplo-mcp.ts +14 -2
@@ -486,6 +486,25 @@ const AuthenticationSchema = z.discriminatedUnion("type", [
486
486
  authorizationParams: z.record(z.string(), z.string()).optional(),
487
487
  forwardAuthorizationParams: z.array(z.string()).optional(),
488
488
  }),
489
+ z.object({
490
+ type: z.literal("entra"),
491
+ basePath: z.string().optional(),
492
+ clientId: z.string(),
493
+ // Tenant id or one of Entra's multi-tenant authorities
494
+ // (common/organizations/consumers). Defaults to "common".
495
+ tenantId: z.string().optional(),
496
+ // Full issuer override, e.g. for CIAM tenants on *.ciamlogin.com.
497
+ issuer: z.string().optional(),
498
+ audience: z.string().optional(),
499
+ scopes: z.array(z.string()).optional(),
500
+ redirectToAfterSignUp: z.string().optional(),
501
+ redirectToAfterSignIn: z.string().optional(),
502
+ redirectToAfterSignOut: z.string().optional(),
503
+ signUp: SignUpOpenIdSchema.optional(),
504
+ disableSignUp: z.boolean().optional(),
505
+ authorizationParams: z.record(z.string(), z.string()).optional(),
506
+ forwardAuthorizationParams: z.array(z.string()).optional(),
507
+ }),
489
508
  z.object({
490
509
  type: z.literal("azureb2c"),
491
510
  basePath: z.string().optional(),
@@ -1,5 +1,8 @@
1
1
  import type { ZudokuConfig } from "../../config/validators/ZudokuConfig.js";
2
- import { getClerkFrontendApi } from "../authentication/providers/util.js";
2
+ import {
3
+ getClerkFrontendApi,
4
+ getEntraIssuer,
5
+ } from "../authentication/providers/util.js";
3
6
 
4
7
  export const getIssuer = async (config: ZudokuConfig) => {
5
8
  switch (config.authentication?.type) {
@@ -12,6 +15,9 @@ export const getIssuer = async (config: ZudokuConfig) => {
12
15
  case "openid": {
13
16
  return config.authentication.issuer;
14
17
  }
18
+ case "entra": {
19
+ return getEntraIssuer(config.authentication);
20
+ }
15
21
  case "supabase": {
16
22
  return config.authentication.supabaseUrl;
17
23
  }
@@ -0,0 +1,97 @@
1
+ import type * as oauth from "oauth4webapi";
2
+ import type { EntraAuthenticationConfig } from "../../../config/config.js";
3
+ import type {
4
+ AuthenticationPlugin,
5
+ AuthenticationProviderInitializer,
6
+ } from "../authentication.js";
7
+ import type { UserProfile } from "../state.js";
8
+ import { OpenIDAuthenticationProvider } from "./openid.js";
9
+ import { getEntraIssuer } from "./util.js";
10
+
11
+ const ISSUER_TENANT_PLACEHOLDER = "{tenantid}";
12
+
13
+ export class EntraAuthenticationProvider
14
+ extends OpenIDAuthenticationProvider
15
+ implements AuthenticationPlugin
16
+ {
17
+ constructor(config: EntraAuthenticationConfig) {
18
+ super({ ...config, type: "openid", issuer: getEntraIssuer(config) });
19
+ }
20
+
21
+ // Multi-tenant authorities advertise a templated issuer that never matches
22
+ // the requested authority, so the strict discovery check would throw. Expect
23
+ // the template itself; resolveTokenIssuer resolves the concrete tenant per
24
+ // token. Recommended by oauth4webapi's maintainer:
25
+ // https://github.com/panva/oauth4webapi/issues/100
26
+ protected override async getExpectedDiscoveryIssuer(
27
+ issuerUrl: URL,
28
+ response: Response,
29
+ ): Promise<URL> {
30
+ try {
31
+ const metadata = (await response.clone().json()) as { issuer?: unknown };
32
+ return typeof metadata.issuer === "string" &&
33
+ metadata.issuer.includes(ISSUER_TENANT_PLACEHOLDER)
34
+ ? new URL(metadata.issuer)
35
+ : issuerUrl;
36
+ } catch {
37
+ // Malformed body: keep the strict path so processDiscoveryResponse
38
+ // surfaces the real error.
39
+ return issuerUrl;
40
+ }
41
+ }
42
+
43
+ // Without a usable ID token the template stays in place and fails the strict
44
+ // `iss` validation loudly.
45
+ protected override async resolveTokenIssuer(
46
+ as: oauth.AuthorizationServer,
47
+ response: Response,
48
+ ): Promise<oauth.AuthorizationServer> {
49
+ if (!as.issuer.includes(ISSUER_TENANT_PLACEHOLDER)) return as;
50
+ try {
51
+ const body = (await response.clone().json()) as { id_token?: unknown };
52
+ if (typeof body.id_token !== "string") return as;
53
+ const { decodeJwt } = await import("jose");
54
+ const { tid } = decodeJwt(body.id_token);
55
+ return typeof tid === "string" && tid.length > 0
56
+ ? {
57
+ ...as,
58
+ issuer: as.issuer.replace(ISSUER_TENANT_PLACEHOLDER, () => tid),
59
+ }
60
+ : as;
61
+ } catch {
62
+ return as;
63
+ }
64
+ }
65
+
66
+ // Entra's `email` claim is only present when the account has a mailbox, which
67
+ // isn't guaranteed — often absent for users from external tenants. Its
68
+ // UserInfo endpoint also omits `preferred_username`, so the base profile
69
+ // (built from UserInfo) can lack any usable identifier. Fill `email` from the
70
+ // ID token's username claims, and surface the immutable `oid`/`tid` for
71
+ // reliable identity and tenant checks. `email`/`preferred_username` are
72
+ // mutable — kept unverified and unsuitable for authorization.
73
+ // https://learn.microsoft.com/en-us/entra/identity-platform/id-token-claims-reference
74
+ protected override buildUserProfile(
75
+ userInfo: oauth.UserInfoResponse,
76
+ claims: oauth.IDToken | undefined,
77
+ ): UserProfile {
78
+ const profile = super.buildUserProfile(userInfo, claims);
79
+ const claim = (name: string) =>
80
+ typeof claims?.[name] === "string" ? claims[name] : undefined;
81
+
82
+ const preferredUsername = claim("preferred_username");
83
+ return {
84
+ ...profile,
85
+ email: profile.email ?? preferredUsername ?? claim("upn"),
86
+ ...(preferredUsername && { preferred_username: preferredUsername }),
87
+ ...(claim("oid") && { oid: claim("oid") }),
88
+ ...(claim("tid") && { tid: claim("tid") }),
89
+ };
90
+ }
91
+ }
92
+
93
+ const entraAuth: AuthenticationProviderInitializer<
94
+ EntraAuthenticationConfig
95
+ > = (options) => new EntraAuthenticationProvider(options);
96
+
97
+ export default entraAuth;
@@ -143,14 +143,32 @@ export class OpenIDAuthenticationProvider
143
143
  issuerUrl,
144
144
  this.oauthOptions,
145
145
  );
146
+
146
147
  this.authorizationServer = await oauth.processDiscoveryResponse(
147
- issuerUrl,
148
+ await this.getExpectedDiscoveryIssuer(issuerUrl, response),
148
149
  response,
149
150
  );
150
151
  }
151
152
  return this.authorizationServer;
152
153
  }
153
154
 
155
+ // Override points for providers whose issuers deviate from the spec (see
156
+ // EntraAuthenticationProvider). Overrides must peek via `response.clone()`;
157
+ // the body is consumed downstream.
158
+ protected async getExpectedDiscoveryIssuer(
159
+ issuerUrl: URL,
160
+ _response: Response,
161
+ ): Promise<URL> {
162
+ return issuerUrl;
163
+ }
164
+
165
+ protected async resolveTokenIssuer(
166
+ as: oauth.AuthorizationServer,
167
+ _response: Response,
168
+ ): Promise<oauth.AuthorizationServer> {
169
+ return as;
170
+ }
171
+
154
172
  /**
155
173
  * Sets the tokens from various OAuth responses
156
174
  * @param response
@@ -239,12 +257,12 @@ export class OpenIDAuthenticationProvider
239
257
  });
240
258
  }
241
259
 
242
- private buildUserProfile(
260
+ protected buildUserProfile(
243
261
  userInfo: oauth.UserInfoResponse,
244
- fallbackEmailVerified: oauth.JsonValue | undefined,
262
+ claims: oauth.IDToken | undefined,
245
263
  ): UserProfile {
246
264
  const emailVerified =
247
- userInfo.email_verified ?? fallbackEmailVerified ?? false;
265
+ userInfo.email_verified ?? claims?.email_verified ?? false;
248
266
  return {
249
267
  ...userInfo,
250
268
  sub: userInfo.sub,
@@ -303,12 +321,10 @@ export class OpenIDAuthenticationProvider
303
321
  const userInfo = await userInfoResponse.json();
304
322
 
305
323
  const { providerData } = useAuthState.getState();
306
- const emailVerified =
307
- providerData?.type === "openid"
308
- ? providerData.claims?.email_verified
309
- : undefined;
324
+ const claims =
325
+ providerData?.type === "openid" ? providerData.claims : undefined;
310
326
 
311
- const profile = this.buildUserProfile(userInfo, emailVerified);
327
+ const profile = this.buildUserProfile(userInfo, claims);
312
328
 
313
329
  useAuthState.setState({
314
330
  isAuthenticated: true,
@@ -496,7 +512,7 @@ export class OpenIDAuthenticationProvider
496
512
  );
497
513
 
498
514
  const result = await oauth.processRefreshTokenResponse(
499
- as,
515
+ await this.resolveTokenIssuer(as, response),
500
516
  this.client,
501
517
  response,
502
518
  );
@@ -590,7 +606,7 @@ export class OpenIDAuthenticationProvider
590
606
  this.oauthOptions,
591
607
  );
592
608
  const result = await oauth.processRefreshTokenResponse(
593
- as,
609
+ await this.resolveTokenIssuer(as, response),
594
610
  this.client,
595
611
  response,
596
612
  );
@@ -657,7 +673,7 @@ export class OpenIDAuthenticationProvider
657
673
  );
658
674
 
659
675
  const oauthResult = await oauth.processAuthorizationCodeResponse(
660
- authServer,
676
+ await this.resolveTokenIssuer(authServer, response),
661
677
  this.client,
662
678
  response,
663
679
  );
@@ -678,7 +694,7 @@ export class OpenIDAuthenticationProvider
678
694
  );
679
695
  const userInfo = await userInfoResponse.json();
680
696
 
681
- const profile = this.buildUserProfile(userInfo, claims?.email_verified);
697
+ const profile = this.buildUserProfile(userInfo, claims);
682
698
 
683
699
  useAuthState.setState({
684
700
  isAuthenticated: true,
@@ -12,6 +12,13 @@ export const redirectToSignUpUrl = (
12
12
  }
13
13
  };
14
14
 
15
+ export const getEntraIssuer = (config: {
16
+ issuer?: string;
17
+ tenantId?: string;
18
+ }) =>
19
+ config.issuer ??
20
+ `https://login.microsoftonline.com/${config.tenantId ?? "common"}/v2.0`;
21
+
15
22
  export const getClerkFrontendApi = (publishableKey: string) => {
16
23
  // Split by underscore and get the base64 encoded part (3rd segment)
17
24
  const parts = publishableKey.split("_");
@@ -63,11 +63,12 @@ const NavigationCategoryInner = ({
63
63
  setOpen((prev) => !prev);
64
64
  setHasInteracted(true);
65
65
  }}
66
- variant="ghost"
66
+ variant="none"
67
67
  size="icon"
68
68
  aria-label={open ? "Collapse section" : "Expand section"}
69
69
  aria-expanded={open}
70
- className="size-6 hover:bg-[hsl(from_var(--accent)_h_s_calc(l+6*var(--dark)))]"
70
+ // -my-0.5 keeps the chevron from making folder rows taller than plain ones
71
+ className="size-6 -my-0.5"
71
72
  >
72
73
  <ChevronRightIcon
73
74
  size={16}
@@ -24,7 +24,7 @@ export const StackDrillRow = ({
24
24
  className={cn(navigationListItem(), "group justify-between", className)}
25
25
  >
26
26
  <span className="flex items-center gap-2 truncate">{children}</span>
27
- <span className="grid size-6 shrink-0 place-items-center">
27
+ <span className="grid size-6 -my-0.5 shrink-0 place-items-center">
28
28
  <ChevronRightIcon size={16} />
29
29
  </span>
30
30
  </Link>