zudoku 0.78.0 → 0.78.2

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 (91) hide show
  1. package/dist/cli/cli.js +602 -160
  2. package/dist/cli/worker.js +6 -4
  3. package/dist/declarations/app/adapter.d.ts +12 -0
  4. package/dist/declarations/app/adapters/cloudflare.d.ts +8 -0
  5. package/dist/declarations/app/adapters/lambda.d.ts +13 -0
  6. package/dist/declarations/app/adapters/node.d.ts +12 -0
  7. package/dist/declarations/app/adapters/vercel.d.ts +14 -0
  8. package/dist/declarations/app/entry.client.d.ts +2 -0
  9. package/dist/declarations/app/entry.server.d.ts +13 -0
  10. package/dist/declarations/app/protectChunks.d.ts +17 -0
  11. package/dist/declarations/app/wrapProtectedRoutes.d.ts +4 -0
  12. package/dist/declarations/config/validators/HeaderNavigationSchema.d.ts +216 -80
  13. package/dist/declarations/config/validators/ZudokuConfig.d.ts +81 -30
  14. package/dist/declarations/config/validators/icon-types.d.ts +1 -1
  15. package/dist/declarations/lib/authentication/authentication.d.ts +7 -0
  16. package/dist/declarations/lib/authentication/cookie-sync.d.ts +3 -0
  17. package/dist/declarations/lib/authentication/cookies.d.ts +10 -0
  18. package/dist/declarations/lib/authentication/providers/azureb2c.d.ts +6 -1
  19. package/dist/declarations/lib/authentication/providers/clerk.d.ts +1 -0
  20. package/dist/declarations/lib/authentication/providers/openid.d.ts +2 -1
  21. package/dist/declarations/lib/authentication/providers/supabase.d.ts +2 -0
  22. package/dist/declarations/lib/authentication/session-handler.d.ts +81 -0
  23. package/dist/declarations/lib/authentication/state.d.ts +7 -0
  24. package/dist/declarations/lib/authentication/verify-cache.d.ts +2 -0
  25. package/dist/declarations/lib/components/Bootstrap.d.ts +2 -2
  26. package/dist/declarations/lib/components/Heading.d.ts +1 -1
  27. package/dist/declarations/lib/components/context/RenderContext.d.ts +6 -0
  28. package/dist/declarations/lib/core/RouteGuard.d.ts +11 -0
  29. package/dist/declarations/lib/core/ZudokuContext.d.ts +5 -2
  30. package/dist/declarations/lib/manifest.d.ts +25 -0
  31. package/dist/declarations/lib/util/url.d.ts +2 -0
  32. package/dist/flat-config.d.ts +1 -1
  33. package/docs/configuration/protected-routes.md +21 -4
  34. package/docs/guides/server-side-content-protection.md +207 -0
  35. package/package.json +26 -4
  36. package/src/app/adapter.ts +16 -0
  37. package/src/app/adapters/cloudflare.ts +18 -0
  38. package/src/app/adapters/lambda.ts +36 -0
  39. package/src/app/adapters/node.ts +32 -0
  40. package/src/app/adapters/vercel.ts +39 -0
  41. package/src/app/demo.tsx +2 -2
  42. package/src/app/entry.client.tsx +19 -7
  43. package/src/app/entry.server.tsx +133 -9
  44. package/src/app/main.tsx +21 -3
  45. package/src/app/protectChunks.ts +64 -0
  46. package/src/app/standalone.tsx +2 -2
  47. package/src/app/wrapProtectedRoutes.ts +82 -0
  48. package/src/config/validators/icon-types.ts +17 -0
  49. package/src/lib/authentication/authentication.ts +15 -0
  50. package/src/lib/authentication/cookie-sync.ts +90 -0
  51. package/src/lib/authentication/cookies.ts +54 -0
  52. package/src/lib/authentication/hook.ts +13 -0
  53. package/src/lib/authentication/providers/azureb2c.tsx +70 -2
  54. package/src/lib/authentication/providers/clerk.tsx +49 -0
  55. package/src/lib/authentication/providers/openid.tsx +46 -0
  56. package/src/lib/authentication/providers/supabase.tsx +30 -2
  57. package/src/lib/authentication/session-handler.ts +164 -0
  58. package/src/lib/authentication/state.ts +36 -5
  59. package/src/lib/authentication/verify-cache.ts +32 -0
  60. package/src/lib/components/Bootstrap.tsx +20 -14
  61. package/src/lib/components/Header.tsx +56 -57
  62. package/src/lib/components/MobileTopNavigation.tsx +66 -67
  63. package/src/lib/components/Zudoku.tsx +14 -1
  64. package/src/lib/components/context/RenderContext.ts +8 -0
  65. package/src/lib/components/context/ZudokuContext.ts +2 -1
  66. package/src/lib/core/RouteGuard.tsx +50 -29
  67. package/src/lib/core/ZudokuContext.ts +39 -6
  68. package/src/lib/errors/RouterError.tsx +43 -1
  69. package/src/lib/manifest.ts +62 -0
  70. package/src/lib/oas/parser/dereference/index.ts +2 -1
  71. package/src/lib/oas/parser/dereference/resolveRef.ts +2 -1
  72. package/src/lib/oas/parser/index.ts +1 -1
  73. package/src/lib/plugins/openapi/client/createServer.ts +13 -4
  74. package/src/lib/plugins/search-pagefind/index.tsx +1 -4
  75. package/src/lib/util/os.ts +1 -0
  76. package/src/lib/util/url.ts +13 -0
  77. package/src/vite/build.ts +84 -24
  78. package/src/vite/config.ts +51 -5
  79. package/src/vite/dev-server.ts +61 -8
  80. package/src/vite/manifest.ts +15 -0
  81. package/src/vite/plugin-api.ts +3 -1
  82. package/src/vite/plugin-markdown-export.ts +3 -9
  83. package/src/vite/prerender/worker.ts +2 -4
  84. package/src/vite/protected/annotator.ts +136 -0
  85. package/src/vite/protected/build.ts +151 -0
  86. package/src/vite/protected/registry.ts +82 -0
  87. package/src/vite/ssr-templates/cloudflare.ts +5 -18
  88. package/src/vite/ssr-templates/lambda.ts +4 -0
  89. package/src/vite/ssr-templates/node.ts +7 -22
  90. package/src/vite/ssr-templates/vercel.ts +6 -20
  91. package/src/vite-env.d.ts +1 -0
@@ -1,8 +1,12 @@
1
1
  import type { NavigateFunction } from "react-router";
2
2
  import type { ZudokuContext } from "../core/ZudokuContext.js";
3
+ import type { UserProfile } from "./state.js";
3
4
 
4
5
  export type AuthActionContext = { navigate: NavigateFunction };
5
6
  export type AuthActionOptions = { redirectTo?: string; replace?: boolean };
7
+ export type VerifyAccessTokenResult =
8
+ | { profile: UserProfile; expiresAt?: number; refreshExpiresAt?: number }
9
+ | undefined;
6
10
 
7
11
  export interface AuthenticationPlugin {
8
12
  // Hides Register UI; real enforcement still belongs at the IdP.
@@ -44,6 +48,17 @@ export interface AuthenticationPlugin {
44
48
  * @deprecated use the navigate function from the AuthActionContext instead
45
49
  */
46
50
  setNavigate?(navigate: NavigateFunction): void;
51
+
52
+ /**
53
+ * Server-side verification of a client-submitted access token, called by
54
+ * the session-handler before setting the SSR auth cookies. Implementations
55
+ * MUST validate against the IdP (signature, issuer, audience, expiry) and
56
+ * return the verified profile. Return undefined for a rejected token
57
+ * (→ 401); throw for misconfig / upstream failure (→ 502). `expiresAt` /
58
+ * `refreshExpiresAt` are unix seconds used to bound cookie lifetimes so
59
+ * SSR can't outlive a revoked token. Omit to opt out of SSR auth (→ 501).
60
+ */
61
+ verifyAccessToken?(token: string): Promise<VerifyAccessTokenResult>;
47
62
  }
48
63
 
49
64
  export type AuthenticationProviderInitializer<TConfig> = (
@@ -0,0 +1,90 @@
1
+ import type { StoreApi } from "zustand";
2
+ import type { AuthState } from "./state.js";
3
+
4
+ type TokenBearer = { accessToken?: string; refreshToken?: string };
5
+
6
+ const readTokens = (providerData: unknown): TokenBearer => {
7
+ if (!providerData || typeof providerData !== "object") return {};
8
+ const data = providerData as Record<string, unknown>;
9
+ return {
10
+ accessToken:
11
+ typeof data.accessToken === "string" ? data.accessToken : undefined,
12
+ refreshToken:
13
+ typeof data.refreshToken === "string" ? data.refreshToken : undefined,
14
+ };
15
+ };
16
+
17
+ // Mirror client auth state to SSR cookies so the next HTML render is authed
18
+ // on first paint. Reads tokens off providerData. No-op on the server.
19
+ export const setupCookieSync = (
20
+ store: StoreApi<
21
+ Pick<AuthState, "isAuthenticated" | "profile" | "providerData">
22
+ >,
23
+ sessionEndpoint: string,
24
+ ) => {
25
+ if (typeof window === "undefined") return;
26
+
27
+ // In-flight controller so a later auth event can abort an earlier request.
28
+ // Without this, a logout can race a slow login and the late POST would
29
+ // re-establish the session after the user explicitly signed out.
30
+ let inflight: AbortController | undefined;
31
+
32
+ const send = (init: RequestInit) => {
33
+ inflight?.abort();
34
+ inflight = new AbortController();
35
+ return fetch(sessionEndpoint, { ...init, signal: inflight.signal });
36
+ };
37
+
38
+ const postSession = async (providerData: unknown) => {
39
+ const { accessToken, refreshToken } = readTokens(providerData);
40
+ if (!accessToken) return;
41
+
42
+ try {
43
+ const r = await send({
44
+ method: "POST",
45
+ headers: { "Content-Type": "application/json" },
46
+ body: JSON.stringify({ accessToken, refreshToken }),
47
+ });
48
+ // 501 = provider opted out of SSR auth; any other failure is surfaced.
49
+ if (!r.ok && r.status !== 501) {
50
+ // biome-ignore lint/suspicious/noConsole: Surface SSR auth failures
51
+ console.error("SSR auth cookie sync failed:", r.status);
52
+ }
53
+ } catch (e) {
54
+ if ((e as Error)?.name === "AbortError") return;
55
+ // biome-ignore lint/suspicious/noConsole: Surface SSR auth failures
56
+ console.error("SSR auth cookie sync error:", e);
57
+ }
58
+ };
59
+
60
+ const clearSession = async () => {
61
+ try {
62
+ await send({ method: "DELETE" });
63
+ } catch (e) {
64
+ if ((e as Error)?.name === "AbortError") return;
65
+ // biome-ignore lint/suspicious/noConsole: Surface SSR auth failures
66
+ console.error("SSR auth cookie clear failed:", e);
67
+ }
68
+ };
69
+
70
+ store.subscribe((next, prev) => {
71
+ if (next.isAuthenticated && next.profile) {
72
+ if (!prev.isAuthenticated || next.providerData !== prev.providerData) {
73
+ void postSession(next.providerData);
74
+ }
75
+ } else if (!next.isAuthenticated && prev.isAuthenticated) {
76
+ void clearSession();
77
+ }
78
+ });
79
+
80
+ // If persist rehydrated an authed session that SSR didn't see, push tokens
81
+ // up so the next navigation is server-authed.
82
+ const state = store.getState();
83
+ if (
84
+ state.isAuthenticated &&
85
+ state.profile &&
86
+ !window.ZUDOKU_SSR_AUTH?.profile
87
+ ) {
88
+ void postSession(state.providerData);
89
+ }
90
+ };
@@ -0,0 +1,54 @@
1
+ import type { UserProfile } from "./state.js";
2
+
3
+ export const ACCESS_TOKEN_COOKIE = "zudoku-access-token";
4
+ export const REFRESH_TOKEN_COOKIE = "zudoku-refresh-token";
5
+ export const AUTH_PROFILE_COOKIE = "zudoku-auth-profile";
6
+
7
+ export type AuthCookies = {
8
+ accessToken: string | undefined;
9
+ refreshToken: string | undefined;
10
+ profile: UserProfile | undefined;
11
+ };
12
+
13
+ const safeDecode = (value: string): string | undefined => {
14
+ try {
15
+ return decodeURIComponent(value);
16
+ } catch {
17
+ return undefined;
18
+ }
19
+ };
20
+
21
+ export const parseCookies = (request: Request): AuthCookies => {
22
+ const header = request.headers.get("Cookie") ?? "";
23
+
24
+ if (!header) {
25
+ return {
26
+ accessToken: undefined,
27
+ refreshToken: undefined,
28
+ profile: undefined,
29
+ };
30
+ }
31
+
32
+ const cookies: Record<string, string> = {};
33
+ for (const part of header.split(";")) {
34
+ const [key, ...rest] = part.trim().split("=");
35
+ if (!key) continue;
36
+ const decoded = safeDecode(rest.join("="));
37
+ if (decoded !== undefined) cookies[key] = decoded;
38
+ }
39
+
40
+ let profile: UserProfile | undefined;
41
+ try {
42
+ if (cookies[AUTH_PROFILE_COOKIE]) {
43
+ profile = JSON.parse(cookies[AUTH_PROFILE_COOKIE]);
44
+ }
45
+ } catch {
46
+ // ignore malformed cookie
47
+ }
48
+
49
+ return {
50
+ accessToken: cookies[ACCESS_TOKEN_COOKIE] || undefined,
51
+ refreshToken: cookies[REFRESH_TOKEN_COOKIE] || undefined,
52
+ profile,
53
+ };
54
+ };
@@ -1,5 +1,7 @@
1
1
  import { useQuery } from "@tanstack/react-query";
2
+ import { use } from "react";
2
3
  import { useNavigate } from "react-router";
4
+ import { RenderContext } from "../components/context/RenderContext.js";
3
5
  import { useZudoku } from "../components/context/ZudokuContext.js";
4
6
  import type { AuthActionOptions } from "./authentication.js";
5
7
  import { useAuthState } from "./state.js";
@@ -74,10 +76,21 @@ export const useAuth = () => {
74
76
 
75
77
  useRefreshUserProfile();
76
78
 
79
+ // On the server, the zustand store can't read window.ZUDOKU_SSR_AUTH, so
80
+ // override from RenderContext which carries the per-request auth state.
81
+ const { ssrAuth } = use(RenderContext);
82
+ const isSSR = typeof window === "undefined";
83
+
77
84
  return {
78
85
  isAuthEnabled,
79
86
  disableSignUp: authentication?.disableSignUp ?? false,
80
87
  ...authState,
88
+ ...(isSSR &&
89
+ ssrAuth && {
90
+ isAuthenticated: !!ssrAuth.profile,
91
+ isPending: false,
92
+ profile: ssrAuth.profile,
93
+ }),
81
94
 
82
95
  login: async (options?: AuthActionOptions) => {
83
96
  if (!isAuthEnabled) {
@@ -8,6 +8,7 @@ import type {
8
8
  AuthActionContext,
9
9
  AuthenticationPlugin,
10
10
  AuthenticationProviderInitializer,
11
+ VerifyAccessTokenResult,
11
12
  } from "../authentication.js";
12
13
  import { CoreAuthenticationPlugin } from "../AuthenticationPlugin.js";
13
14
  import { CallbackHandler } from "../components/CallbackHandler.js";
@@ -43,6 +44,10 @@ export class AzureB2CAuthPlugin
43
44
  private readonly redirectToAfterSignOut: string;
44
45
  private readonly signUpConfig?: AzureB2CAuthenticationConfig["signUp"];
45
46
  public readonly disableSignUp: boolean;
47
+ private readonly authority: string;
48
+ // The issuer carries the tenant GUID we don't know up-front, so discover.
49
+ private discoveryPromise?: Promise<{ issuer: string; jwks_uri: string }>;
50
+ private jwks?: ReturnType<typeof import("jose").createRemoteJWKSet>;
46
51
 
47
52
  constructor({
48
53
  clientId,
@@ -64,13 +69,13 @@ export class AzureB2CAuthPlugin
64
69
  this.signUpConfig = signUp;
65
70
  this.disableSignUp = disableSignUp ?? false;
66
71
 
67
- const authority = `https://${tenantName}.b2clogin.com/${tenantName}.onmicrosoft.com/${policyName}`;
72
+ this.authority = `https://${tenantName}.b2clogin.com/${tenantName}.onmicrosoft.com/${policyName}`;
68
73
  const redirectUri = joinUrl(basePath, AZUREB2C_CALLBACK_PATH);
69
74
 
70
75
  this.msalInstance = new PublicClientApplication({
71
76
  auth: {
72
77
  clientId,
73
- authority,
78
+ authority: this.authority,
74
79
  redirectUri,
75
80
  knownAuthorities: [`${tenantName}.b2clogin.com`],
76
81
  },
@@ -192,6 +197,69 @@ export class AzureB2CAuthPlugin
192
197
  return request;
193
198
  };
194
199
 
200
+ private async getDiscovery() {
201
+ this.discoveryPromise ??= fetch(
202
+ `${this.authority}/v2.0/.well-known/openid-configuration`,
203
+ ).then(async (response) => {
204
+ if (!response.ok) {
205
+ throw new Error(
206
+ `Azure B2C discovery failed: ${response.status} ${response.statusText}`,
207
+ );
208
+ }
209
+ return (await response.json()) as { issuer: string; jwks_uri: string };
210
+ });
211
+ return this.discoveryPromise;
212
+ }
213
+
214
+ async verifyAccessToken(token: string): Promise<VerifyAccessTokenResult> {
215
+ const jose = await import("jose");
216
+ const { issuer, jwks_uri } = await this.getDiscovery();
217
+ if (!this.jwks) {
218
+ this.jwks = jose.createRemoteJWKSet(new URL(jwks_uri));
219
+ }
220
+ try {
221
+ const { payload } = await jose.jwtVerify(token, this.jwks, { issuer });
222
+ // Match client-side sub (handleAuthResponse uses account.localAccountId, which is oid).
223
+ const sub =
224
+ typeof payload.oid === "string"
225
+ ? payload.oid
226
+ : typeof payload.sub === "string"
227
+ ? payload.sub
228
+ : undefined;
229
+ if (!sub) return undefined;
230
+
231
+ const emails = Array.isArray(payload.emails) ? payload.emails : [];
232
+ const email =
233
+ typeof payload.email === "string"
234
+ ? payload.email
235
+ : typeof emails[0] === "string"
236
+ ? emails[0]
237
+ : undefined;
238
+
239
+ const fullName = [payload.given_name, payload.family_name]
240
+ .filter((s): s is string => typeof s === "string" && s.length > 0)
241
+ .join(" ");
242
+ const name =
243
+ typeof payload.name === "string" ? payload.name : fullName || undefined;
244
+
245
+ return {
246
+ profile: {
247
+ sub,
248
+ email,
249
+ name,
250
+ emailVerified: true,
251
+ pictureUrl: undefined,
252
+ },
253
+ expiresAt: typeof payload.exp === "number" ? payload.exp : undefined,
254
+ };
255
+ } catch (e) {
256
+ // JOSEError = invalid token (→ 401). Rethrow anything else so the
257
+ // handler can surface 502 for misconfig / JWKS fetch failures.
258
+ if (e instanceof jose.errors.JOSEError) return undefined;
259
+ throw e;
260
+ }
261
+ }
262
+
195
263
  signOut = async (_: AuthActionContext) => {
196
264
  const account = this.msalInstance.getAllAccounts()[0];
197
265
  if (account) {
@@ -4,6 +4,7 @@ import type {
4
4
  AuthActionContext,
5
5
  AuthenticationPlugin,
6
6
  AuthenticationProviderInitializer,
7
+ VerifyAccessTokenResult,
7
8
  } from "../authentication.js";
8
9
  import { SignIn } from "../components/SignIn.js";
9
10
  import { SignOut } from "../components/SignOut.js";
@@ -46,6 +47,7 @@ type Clerk = {
46
47
  export type ClerkProviderData = {
47
48
  type: "clerk";
48
49
  user: ClerkUser | undefined;
50
+ accessToken?: string;
49
51
  };
50
52
 
51
53
  declare module "../state.js" {
@@ -103,6 +105,13 @@ const clerkAuth: AuthenticationProviderInitializer<
103
105
  return loadClerk(clerkPubKey);
104
106
  };
105
107
 
108
+ let cachedIssuer: string | undefined;
109
+ const getIssuer = () => {
110
+ cachedIssuer ??= `https://${getClerkFrontendApi(clerkPubKey)}`;
111
+ return cachedIssuer;
112
+ };
113
+ let jwks: ReturnType<typeof import("jose").createRemoteJWKSet> | undefined;
114
+
106
115
  async function getAccessToken() {
107
116
  const clerk = await getClerk();
108
117
 
@@ -156,6 +165,8 @@ const clerkAuth: AuthenticationProviderInitializer<
156
165
  return false;
157
166
  }
158
167
 
168
+ const accessToken = await getAccessToken().catch(() => undefined);
169
+
159
170
  useAuthState.setState({
160
171
  isAuthenticated: true,
161
172
  isPending: false,
@@ -163,6 +174,7 @@ const clerkAuth: AuthenticationProviderInitializer<
163
174
  providerData: {
164
175
  type: "clerk",
165
176
  user: clerk.session?.user,
177
+ accessToken,
166
178
  },
167
179
  });
168
180
 
@@ -175,6 +187,39 @@ const clerkAuth: AuthenticationProviderInitializer<
175
187
  return request;
176
188
  }
177
189
 
190
+ async function verifyAccessToken(
191
+ token: string,
192
+ ): Promise<VerifyAccessTokenResult> {
193
+ const jose = await import("jose");
194
+ const issuer = getIssuer();
195
+ if (!jwks) {
196
+ jwks = jose.createRemoteJWKSet(
197
+ new URL(`${issuer}/.well-known/jwks.json`),
198
+ );
199
+ }
200
+ try {
201
+ const { payload } = await jose.jwtVerify(token, jwks, { issuer });
202
+ if (!payload.sub) return undefined;
203
+ return {
204
+ profile: {
205
+ sub: String(payload.sub),
206
+ email: (payload.email ?? payload.email_address) as string | undefined,
207
+ name: payload.name as string | undefined,
208
+ emailVerified: Boolean(payload.email_verified),
209
+ pictureUrl: (payload.picture ?? payload.image_url) as
210
+ | string
211
+ | undefined,
212
+ },
213
+ expiresAt: typeof payload.exp === "number" ? payload.exp : undefined,
214
+ };
215
+ } catch (e) {
216
+ // JOSEError = invalid token (→ 401). Rethrow anything else so the
217
+ // handler can surface 502 for misconfig / JWKS fetch failures.
218
+ if (e instanceof jose.errors.JOSEError) return undefined;
219
+ throw e;
220
+ }
221
+ }
222
+
178
223
  return {
179
224
  disableSignUp: disableSignUp ?? false,
180
225
  getRoutes: () => [
@@ -204,11 +249,14 @@ const clerkAuth: AuthenticationProviderInitializer<
204
249
  return;
205
250
  }
206
251
 
252
+ const accessToken = await getAccessToken().catch(() => undefined);
253
+
207
254
  useAuthState.getState().setLoggedIn({
208
255
  profile,
209
256
  providerData: {
210
257
  type: "clerk",
211
258
  user: clerk.session.user,
259
+ accessToken,
212
260
  },
213
261
  });
214
262
  } else {
@@ -217,6 +265,7 @@ const clerkAuth: AuthenticationProviderInitializer<
217
265
  },
218
266
  getAccessToken,
219
267
  signRequest,
268
+ verifyAccessToken,
220
269
  signOut: async () => {
221
270
  const clerk = await getClerk();
222
271
  useAuthState.getState().setLoggedOut();
@@ -9,6 +9,7 @@ import type {
9
9
  AuthActionOptions,
10
10
  AuthenticationPlugin,
11
11
  AuthenticationProviderInitializer,
12
+ VerifyAccessTokenResult,
12
13
  } from "../authentication.js";
13
14
  import { CoreAuthenticationPlugin } from "../AuthenticationPlugin.js";
14
15
  import { CallbackHandler } from "../components/CallbackHandler.js";
@@ -21,6 +22,16 @@ import { redirectToSignUpUrl } from "./util.js";
21
22
  const CODE_VERIFIER_KEY = "code-verifier";
22
23
  const STATE_KEY = "oauth-state";
23
24
 
25
+ const decodeJwtExp = async (token: string): Promise<number | undefined> => {
26
+ try {
27
+ const { decodeJwt } = await import("jose");
28
+ const payload = decodeJwt(token);
29
+ return typeof payload.exp === "number" ? payload.exp : undefined;
30
+ } catch {
31
+ return undefined;
32
+ }
33
+ };
34
+
24
35
  export interface OpenIdProviderData {
25
36
  // just for easy migration we also allow for undefined type. can be removed in the future.
26
37
  type: "openid" | undefined;
@@ -239,7 +250,42 @@ export class OpenIDAuthenticationProvider
239
250
  };
240
251
  }
241
252
 
253
+ public async verifyAccessToken(
254
+ token: string,
255
+ ): Promise<VerifyAccessTokenResult> {
256
+ const authServer = await this.getAuthServer();
257
+ const response = await oauth.userInfoRequest(
258
+ authServer,
259
+ this.client,
260
+ token,
261
+ );
262
+ if (!response.ok) return undefined;
263
+ const userInfo = (await response.json()) as Record<string, unknown>;
264
+ if (!userInfo.sub) return undefined;
265
+
266
+ // userInfoRequest authenticated the token upstream; parsing `exp` here
267
+ // lets us bound the cookie lifetime to the token's. Opaque tokens just
268
+ // yield undefined and fall back to the handler's default.
269
+ const expiresAt = await decodeJwtExp(token);
270
+
271
+ return {
272
+ profile: {
273
+ sub: String(userInfo.sub),
274
+ email: userInfo.email as string | undefined,
275
+ name: userInfo.name as string | undefined,
276
+ emailVerified: Boolean(userInfo.email_verified),
277
+ pictureUrl: userInfo.picture as string | undefined,
278
+ },
279
+ expiresAt,
280
+ };
281
+ }
282
+
242
283
  public async refreshUserProfile(): Promise<boolean> {
284
+ // SSR mode doesn't persist `providerData`; tokens live only in the
285
+ // httpOnly cookie. Without client-side tokens we can't call userInfo —
286
+ // the SSR-supplied profile stays authoritative.
287
+ if (!useAuthState.getState().providerData) return false;
288
+
243
289
  const accessToken = await this.getAccessToken();
244
290
  const authServer = await this.getAuthServer();
245
291
 
@@ -12,6 +12,7 @@ import type {
12
12
  AuthActionOptions,
13
13
  AuthenticationPlugin,
14
14
  AuthenticationProviderInitializer,
15
+ VerifyAccessTokenResult,
15
16
  } from "../authentication.js";
16
17
  import { CoreAuthenticationPlugin } from "../AuthenticationPlugin.js";
17
18
  import { SignOut } from "../components/SignOut.js";
@@ -30,6 +31,8 @@ import { redirectToSignUpUrl } from "./util.js";
30
31
  export type SupabaseProviderData = {
31
32
  type: "supabase";
32
33
  session: Session;
34
+ accessToken: string;
35
+ refreshToken?: string;
33
36
  };
34
37
 
35
38
  declare module "../state.js" {
@@ -92,7 +95,12 @@ class SupabaseAuthenticationProvider
92
95
 
93
96
  useAuthState.getState().setLoggedIn({
94
97
  profile,
95
- providerData: { type: "supabase", session },
98
+ providerData: {
99
+ type: "supabase",
100
+ session,
101
+ accessToken: session.access_token,
102
+ refreshToken: session.refresh_token,
103
+ },
96
104
  });
97
105
  }
98
106
 
@@ -106,6 +114,21 @@ class SupabaseAuthenticationProvider
106
114
  return data.session.access_token;
107
115
  }
108
116
 
117
+ async verifyAccessToken(token: string): Promise<VerifyAccessTokenResult> {
118
+ const { data, error } = await this.client.auth.getUser(token);
119
+ if (error || !data.user) return undefined;
120
+ const user = data.user;
121
+ return {
122
+ profile: {
123
+ sub: user.id,
124
+ email: user.email,
125
+ name: user.user_metadata.full_name || user.user_metadata.name,
126
+ emailVerified: user.email_confirmed_at != null,
127
+ pictureUrl: user.user_metadata.avatar_url,
128
+ },
129
+ };
130
+ }
131
+
109
132
  async signRequest(request: Request): Promise<Request> {
110
133
  const accessToken = await this.getAccessToken();
111
134
  request.headers.set("Authorization", `Bearer ${accessToken}`);
@@ -214,7 +237,12 @@ class SupabaseAuthenticationProvider
214
237
 
215
238
  useAuthState.getState().setLoggedIn({
216
239
  profile,
217
- providerData: { type: "supabase", session: data.session },
240
+ providerData: {
241
+ type: "supabase",
242
+ session: data.session,
243
+ accessToken: data.session.access_token,
244
+ refreshToken: data.session.refresh_token,
245
+ },
218
246
  });
219
247
  }
220
248
  };