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.
- package/dist/cli/cli.js +602 -160
- package/dist/cli/worker.js +6 -4
- package/dist/declarations/app/adapter.d.ts +12 -0
- package/dist/declarations/app/adapters/cloudflare.d.ts +8 -0
- package/dist/declarations/app/adapters/lambda.d.ts +13 -0
- package/dist/declarations/app/adapters/node.d.ts +12 -0
- package/dist/declarations/app/adapters/vercel.d.ts +14 -0
- package/dist/declarations/app/entry.client.d.ts +2 -0
- package/dist/declarations/app/entry.server.d.ts +13 -0
- package/dist/declarations/app/protectChunks.d.ts +17 -0
- package/dist/declarations/app/wrapProtectedRoutes.d.ts +4 -0
- package/dist/declarations/config/validators/HeaderNavigationSchema.d.ts +216 -80
- package/dist/declarations/config/validators/ZudokuConfig.d.ts +81 -30
- package/dist/declarations/config/validators/icon-types.d.ts +1 -1
- package/dist/declarations/lib/authentication/authentication.d.ts +7 -0
- package/dist/declarations/lib/authentication/cookie-sync.d.ts +3 -0
- package/dist/declarations/lib/authentication/cookies.d.ts +10 -0
- package/dist/declarations/lib/authentication/providers/azureb2c.d.ts +6 -1
- package/dist/declarations/lib/authentication/providers/clerk.d.ts +1 -0
- package/dist/declarations/lib/authentication/providers/openid.d.ts +2 -1
- package/dist/declarations/lib/authentication/providers/supabase.d.ts +2 -0
- package/dist/declarations/lib/authentication/session-handler.d.ts +81 -0
- package/dist/declarations/lib/authentication/state.d.ts +7 -0
- package/dist/declarations/lib/authentication/verify-cache.d.ts +2 -0
- package/dist/declarations/lib/components/Bootstrap.d.ts +2 -2
- package/dist/declarations/lib/components/Heading.d.ts +1 -1
- package/dist/declarations/lib/components/context/RenderContext.d.ts +6 -0
- package/dist/declarations/lib/core/RouteGuard.d.ts +11 -0
- package/dist/declarations/lib/core/ZudokuContext.d.ts +5 -2
- package/dist/declarations/lib/manifest.d.ts +25 -0
- package/dist/declarations/lib/util/url.d.ts +2 -0
- package/dist/flat-config.d.ts +1 -1
- package/docs/configuration/protected-routes.md +21 -4
- package/docs/guides/server-side-content-protection.md +207 -0
- package/package.json +26 -4
- package/src/app/adapter.ts +16 -0
- package/src/app/adapters/cloudflare.ts +18 -0
- package/src/app/adapters/lambda.ts +36 -0
- package/src/app/adapters/node.ts +32 -0
- package/src/app/adapters/vercel.ts +39 -0
- package/src/app/demo.tsx +2 -2
- package/src/app/entry.client.tsx +19 -7
- package/src/app/entry.server.tsx +133 -9
- package/src/app/main.tsx +21 -3
- package/src/app/protectChunks.ts +64 -0
- package/src/app/standalone.tsx +2 -2
- package/src/app/wrapProtectedRoutes.ts +82 -0
- package/src/config/validators/icon-types.ts +17 -0
- package/src/lib/authentication/authentication.ts +15 -0
- package/src/lib/authentication/cookie-sync.ts +90 -0
- package/src/lib/authentication/cookies.ts +54 -0
- package/src/lib/authentication/hook.ts +13 -0
- package/src/lib/authentication/providers/azureb2c.tsx +70 -2
- package/src/lib/authentication/providers/clerk.tsx +49 -0
- package/src/lib/authentication/providers/openid.tsx +46 -0
- package/src/lib/authentication/providers/supabase.tsx +30 -2
- package/src/lib/authentication/session-handler.ts +164 -0
- package/src/lib/authentication/state.ts +36 -5
- package/src/lib/authentication/verify-cache.ts +32 -0
- package/src/lib/components/Bootstrap.tsx +20 -14
- package/src/lib/components/Header.tsx +56 -57
- package/src/lib/components/MobileTopNavigation.tsx +66 -67
- package/src/lib/components/Zudoku.tsx +14 -1
- package/src/lib/components/context/RenderContext.ts +8 -0
- package/src/lib/components/context/ZudokuContext.ts +2 -1
- package/src/lib/core/RouteGuard.tsx +50 -29
- package/src/lib/core/ZudokuContext.ts +39 -6
- package/src/lib/errors/RouterError.tsx +43 -1
- package/src/lib/manifest.ts +62 -0
- package/src/lib/oas/parser/dereference/index.ts +2 -1
- package/src/lib/oas/parser/dereference/resolveRef.ts +2 -1
- package/src/lib/oas/parser/index.ts +1 -1
- package/src/lib/plugins/openapi/client/createServer.ts +13 -4
- package/src/lib/plugins/search-pagefind/index.tsx +1 -4
- package/src/lib/util/os.ts +1 -0
- package/src/lib/util/url.ts +13 -0
- package/src/vite/build.ts +84 -24
- package/src/vite/config.ts +51 -5
- package/src/vite/dev-server.ts +61 -8
- package/src/vite/manifest.ts +15 -0
- package/src/vite/plugin-api.ts +3 -1
- package/src/vite/plugin-markdown-export.ts +3 -9
- package/src/vite/prerender/worker.ts +2 -4
- package/src/vite/protected/annotator.ts +136 -0
- package/src/vite/protected/build.ts +151 -0
- package/src/vite/protected/registry.ts +82 -0
- package/src/vite/ssr-templates/cloudflare.ts +5 -18
- package/src/vite/ssr-templates/lambda.ts +4 -0
- package/src/vite/ssr-templates/node.ts +7 -22
- package/src/vite/ssr-templates/vercel.ts +6 -20
- 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
|
-
|
|
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: {
|
|
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: {
|
|
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
|
};
|