jamdesk 1.1.204 → 1.1.205
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/__tests__/unit/deploy.test.js +106 -0
- package/dist/__tests__/unit/deploy.test.js.map +1 -1
- package/dist/__tests__/unit/jwt-auth-config-drift.test.d.ts +22 -0
- package/dist/__tests__/unit/jwt-auth-config-drift.test.d.ts.map +1 -0
- package/dist/__tests__/unit/jwt-auth-config-drift.test.js +193 -0
- package/dist/__tests__/unit/jwt-auth-config-drift.test.js.map +1 -0
- package/dist/__tests__/unit/validate-jwt-auth.test.d.ts +2 -0
- package/dist/__tests__/unit/validate-jwt-auth.test.d.ts.map +1 -0
- package/dist/__tests__/unit/validate-jwt-auth.test.js +77 -0
- package/dist/__tests__/unit/validate-jwt-auth.test.js.map +1 -0
- package/dist/commands/deploy.js +16 -0
- package/dist/commands/deploy.js.map +1 -1
- package/dist/lib/deps.d.ts.map +1 -1
- package/dist/lib/deps.js +5 -0
- package/dist/lib/deps.js.map +1 -1
- package/dist/lib/docs-config.d.ts +1 -0
- package/dist/lib/docs-config.d.ts.map +1 -1
- package/dist/lib/docs-config.js +49 -0
- package/dist/lib/docs-config.js.map +1 -1
- package/package.json +1 -1
- package/vendored/app/(jd-system)/jd-inactive/BrandedInactive.tsx +1 -7
- package/vendored/app/(jd-system)/jd-inactive/page.tsx +2 -4
- package/vendored/app/(unlock)/jd/auth/callback/CallbackClient.tsx +33 -0
- package/vendored/app/(unlock)/jd/auth/callback/page.tsx +15 -0
- package/vendored/app/(unlock)/jd/unlock/page.tsx +29 -7
- package/vendored/app/api/jd/auth/callback/route.ts +103 -0
- package/vendored/app/api/jd/auth/logout/route.ts +38 -0
- package/vendored/app/api/jd/auth/me/route.ts +52 -0
- package/vendored/app/api/jd/unlock/route.ts +26 -2
- package/vendored/app/layout.tsx +7 -0
- package/vendored/components/mdx/OpenApiEndpoint.tsx +4 -0
- package/vendored/components/navigation/Header.tsx +5 -0
- package/vendored/components/navigation/LogoutButton.tsx +41 -0
- package/vendored/lib/auth-resolver.ts +125 -50
- package/vendored/lib/cached-redis.ts +28 -3
- package/vendored/lib/docs-types.ts +17 -1
- package/vendored/lib/glob-match.ts +54 -0
- package/vendored/lib/jwt-auth-config.ts +37 -0
- package/vendored/lib/jwt-session.ts +98 -0
- package/vendored/lib/mdx-inline-components.ts +1 -1
- package/vendored/lib/middleware-helpers.ts +11 -6
- package/vendored/lib/nav-group-filter.ts +81 -0
- package/vendored/lib/public-paths-resolver.ts +164 -7
- package/vendored/lib/r2-public-storage-guard.ts +26 -4
- package/vendored/lib/redis.ts +20 -2
- package/vendored/lib/render-doc-page.tsx +74 -13
- package/vendored/lib/root-page-slug.ts +20 -1
- package/vendored/lib/same-origin.ts +38 -0
- package/vendored/lib/sanitize-from.ts +31 -1
- package/vendored/lib/session-hint.ts +11 -0
- package/vendored/lib/snippet-compiler-isr.ts +1 -1
- package/vendored/lib/snippet-loader-isr.ts +1 -1
- package/vendored/lib/validate-config.ts +14 -0
- package/vendored/lib/viewer-navigation.ts +45 -0
- package/vendored/lib/visibility.ts +65 -0
- package/vendored/schema/docs-schema.json +25 -0
- package/vendored/shared/auth-cookie.ts +98 -5
- package/vendored/workspace-package-lock.json +73 -63
|
@@ -19,11 +19,12 @@ export const dynamic = 'force-dynamic';
|
|
|
19
19
|
import { scrypt, randomBytes, timingSafeEqual } from 'crypto';
|
|
20
20
|
import { promisify } from 'util';
|
|
21
21
|
import { redis } from '@/lib/redis';
|
|
22
|
-
import { getProjectAuthSecret } from '@/lib/cached-redis';
|
|
22
|
+
import { getProjectAuthSecret, getProjectAuthPublic } from '@/lib/cached-redis';
|
|
23
23
|
import { sanitizeFrom } from '@/lib/sanitize-from';
|
|
24
24
|
import { VALID_SLUG_RE } from '@/lib/middleware-helpers';
|
|
25
25
|
import { signAuthCookie } from '@/shared/auth-cookie';
|
|
26
26
|
import { secretsEqual } from '@/lib/crypto-helpers';
|
|
27
|
+
import { isSameOriginRequest } from '@/lib/same-origin';
|
|
27
28
|
|
|
28
29
|
const scryptAsync = promisify(scrypt) as (
|
|
29
30
|
password: string,
|
|
@@ -230,6 +231,13 @@ async function fetchStoredSecret(slug: string): Promise<StoredSecret | null> {
|
|
|
230
231
|
}
|
|
231
232
|
|
|
232
233
|
export async function POST(req: Request): Promise<Response> {
|
|
234
|
+
// Login-CSRF guard: a cross-site form POST would store a session in the
|
|
235
|
+
// victim's jar. Checked first — before scrypt, before rate limiting — so a
|
|
236
|
+
// cross-site attacker cannot even use the endpoint as a scrypt oracle.
|
|
237
|
+
if (!isSameOriginRequest(req)) {
|
|
238
|
+
return new Response('Forbidden: cross-site request', { status: 403 });
|
|
239
|
+
}
|
|
240
|
+
|
|
233
241
|
// Fail-closed: if the signing secret is missing, we cannot issue valid
|
|
234
242
|
// cookies. Return 503 rather than silently issuing unsigned tokens.
|
|
235
243
|
const secret = process.env.EDGE_AUTH_SECRET;
|
|
@@ -293,8 +301,14 @@ export async function POST(req: Request): Promise<Response> {
|
|
|
293
301
|
}
|
|
294
302
|
|
|
295
303
|
let stored: StoredSecret | null;
|
|
304
|
+
let authType: 'password' | 'jwt' | undefined;
|
|
296
305
|
try {
|
|
297
|
-
|
|
306
|
+
const [storedResult, pubResult] = await Promise.all([
|
|
307
|
+
fetchStoredSecret(slug),
|
|
308
|
+
getProjectAuthPublic(slug),
|
|
309
|
+
]);
|
|
310
|
+
stored = storedResult;
|
|
311
|
+
authType = pubResult?.authType;
|
|
298
312
|
} catch (err) {
|
|
299
313
|
console.error('[unlock] Redis fetch failed — failing closed', {
|
|
300
314
|
slug,
|
|
@@ -305,6 +319,16 @@ export async function POST(req: Request): Promise<Response> {
|
|
|
305
319
|
headers: { 'Retry-After': '30' },
|
|
306
320
|
});
|
|
307
321
|
}
|
|
322
|
+
|
|
323
|
+
// JWT-mode projects don't use this route. During a password→jwt migration
|
|
324
|
+
// coexistence window, a lingering password hash would otherwise let the
|
|
325
|
+
// unlock form verify and mint a v1 cookie the jwt gate rejects — not a
|
|
326
|
+
// bypass (the jwt gate never accepts a v1 cookie), but a confusing dead
|
|
327
|
+
// end for the user. Fail fast instead.
|
|
328
|
+
if (authType === 'jwt') {
|
|
329
|
+
return new Response('not-enabled', { status: 404 });
|
|
330
|
+
}
|
|
331
|
+
|
|
308
332
|
const hashToVerify = stored?.hash ?? DUMMY_HASH;
|
|
309
333
|
|
|
310
334
|
// Always run scrypt — even with DUMMY_HASH when no password is set — to
|
package/vendored/app/layout.tsx
CHANGED
|
@@ -23,6 +23,7 @@ import type { DocsConfig } from '@/lib/docs-types';
|
|
|
23
23
|
import { buildSiteTitle, getFaviconIcons, FALLBACK_METADATA } from '@/lib/seo';
|
|
24
24
|
import { withR2OpsContext, emitR2OpsSummary } from '@/lib/r2-content';
|
|
25
25
|
import { DocsChrome, getLocalFileContent, loadCustomAssets } from '@/lib/layout-helpers';
|
|
26
|
+
import { filterNavigationForViewer } from '@/lib/viewer-navigation';
|
|
26
27
|
|
|
27
28
|
async function generateMetadataImpl(): Promise<Metadata> {
|
|
28
29
|
// The placeholder shell would otherwise render with the paused
|
|
@@ -150,6 +151,12 @@ async function rootLayoutImpl({
|
|
|
150
151
|
try {
|
|
151
152
|
config = await getIsrDocsConfig(projectSlug);
|
|
152
153
|
config = { ...config, hostAtDocs, docsPrefix };
|
|
154
|
+
if (config.auth?.jwt?.enabled) {
|
|
155
|
+
config = {
|
|
156
|
+
...config,
|
|
157
|
+
navigation: await filterNavigationForViewer(projectSlug, config.navigation, headersList),
|
|
158
|
+
};
|
|
159
|
+
}
|
|
153
160
|
} catch {
|
|
154
161
|
config = {
|
|
155
162
|
name: 'Documentation',
|
|
@@ -68,6 +68,9 @@ interface OpenApiEndpointProps {
|
|
|
68
68
|
* are the same text — an author's DIFFERENT frontmatter description must
|
|
69
69
|
* not suppress the operation's own. */
|
|
70
70
|
descriptionAlreadyShown?: boolean;
|
|
71
|
+
/** When true, PlaygroundModal fetches the signed-in JWT session's
|
|
72
|
+
* apiPlaygroundInputs on open and pre-fills matching values. */
|
|
73
|
+
jwtAuthEnabled?: boolean;
|
|
71
74
|
}
|
|
72
75
|
|
|
73
76
|
/**
|
|
@@ -830,6 +833,7 @@ export function OpenApiEndpoint({
|
|
|
830
833
|
languages,
|
|
831
834
|
playgroundOnly,
|
|
832
835
|
descriptionAlreadyShown,
|
|
836
|
+
jwtAuthEnabled,
|
|
833
837
|
}: OpenApiEndpointProps) {
|
|
834
838
|
const {
|
|
835
839
|
method,
|
|
@@ -13,6 +13,7 @@ import { ThemeToggle } from '@/components/theme/ThemeToggle';
|
|
|
13
13
|
import { LazySearchModal as SearchModal } from '@/components/search/LazySearchModal';
|
|
14
14
|
import { DefaultLogo } from './DefaultLogo';
|
|
15
15
|
import { LanguageSelector } from './LanguageSelector';
|
|
16
|
+
import LogoutButton from './LogoutButton';
|
|
16
17
|
import { resolveNavigation } from '@/lib/navigation-resolver';
|
|
17
18
|
import { getIconClass } from '@/lib/icon-utils';
|
|
18
19
|
import { useLinkPrefix } from '@/lib/link-prefix-context';
|
|
@@ -550,6 +551,10 @@ export function Header({ config, layout = 'header-logo', tabsPosition: tabsPosit
|
|
|
550
551
|
</>
|
|
551
552
|
)}
|
|
552
553
|
|
|
554
|
+
{/* Log-out affordance - only mounted for JWT-authenticated docs sites;
|
|
555
|
+
renders nothing until the client confirms an active session. */}
|
|
556
|
+
{config.auth?.jwt?.enabled === true && <LogoutButton />}
|
|
557
|
+
|
|
553
558
|
{/* Theme toggle - hidden on mobile since it's in the sidebar menu.
|
|
554
559
|
Also hidden when appearance.strict pins users to the configured mode. */}
|
|
555
560
|
{!config.appearance?.strict && (
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useEffect, useState } from 'react';
|
|
4
|
+
import { hasJwtSessionHint } from '../../lib/session-hint';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Only mounted when docs.json has auth.jwt.enabled. The jd_authed hint
|
|
8
|
+
* cookie (client-readable, set by the callback, cleared by logout) gates the
|
|
9
|
+
* /me fetch: the Header persists across next/link soft-navs, so this mounts
|
|
10
|
+
* once per hard load — without the hint, every fresh anonymous entry (SEO
|
|
11
|
+
* landing, shared link) would pay a /me round-trip. Login and logout both end
|
|
12
|
+
* in hard loads, so the state re-derives whenever it can change. The hint is
|
|
13
|
+
* advisory only — the signed session cookie behind /me is what's trusted.
|
|
14
|
+
* cursor-pointer per UI rule.
|
|
15
|
+
*/
|
|
16
|
+
export default function LogoutButton() {
|
|
17
|
+
const [visible, setVisible] = useState(false);
|
|
18
|
+
|
|
19
|
+
useEffect(() => {
|
|
20
|
+
if (!hasJwtSessionHint(document.cookie)) return;
|
|
21
|
+
let cancelled = false;
|
|
22
|
+
fetch('/_jd/api/auth/me', { credentials: 'same-origin' })
|
|
23
|
+
.then((r) => r.json())
|
|
24
|
+
.then((d: { authenticated?: boolean; authType?: string }) => {
|
|
25
|
+
if (!cancelled && d.authenticated && d.authType === 'jwt') setVisible(true);
|
|
26
|
+
})
|
|
27
|
+
.catch(() => { /* header must render regardless */ });
|
|
28
|
+
return () => { cancelled = true; };
|
|
29
|
+
}, []);
|
|
30
|
+
|
|
31
|
+
if (!visible) return null;
|
|
32
|
+
return (
|
|
33
|
+
<a
|
|
34
|
+
href="/_jd/auth/logout"
|
|
35
|
+
className="cursor-pointer"
|
|
36
|
+
style={{ color: 'var(--color-text-secondary)', fontSize: '0.875rem', whiteSpace: 'nowrap' }}
|
|
37
|
+
>
|
|
38
|
+
Log out
|
|
39
|
+
</a>
|
|
40
|
+
);
|
|
41
|
+
}
|
|
@@ -1,13 +1,17 @@
|
|
|
1
1
|
import { redis } from './redis';
|
|
2
|
-
import { getProjectAuthSecret, getProjectAuthPublic } from './cached-redis';
|
|
3
|
-
import { matchPublicPath } from './glob-match';
|
|
2
|
+
import { getProjectAuthSecret, getProjectAuthPublic, getProjectJwtSecret } from './cached-redis';
|
|
3
|
+
import { matchPublicPath, resolveRequiredGroups } from './glob-match';
|
|
4
4
|
|
|
5
|
-
export { matchPublicPath };
|
|
5
|
+
export { matchPublicPath, resolveRequiredGroups };
|
|
6
6
|
|
|
7
7
|
export interface ResolvedAuth {
|
|
8
|
-
|
|
8
|
+
authType: 'password' | 'jwt';
|
|
9
|
+
hash?: string;
|
|
10
|
+
publicKeyPem?: string;
|
|
9
11
|
version: number;
|
|
10
12
|
publicPaths: string[];
|
|
13
|
+
loginUrl?: string;
|
|
14
|
+
groupsPaths?: Record<string, string[]>;
|
|
11
15
|
}
|
|
12
16
|
|
|
13
17
|
interface AuthSecret {
|
|
@@ -15,30 +19,50 @@ interface AuthSecret {
|
|
|
15
19
|
version: number;
|
|
16
20
|
}
|
|
17
21
|
|
|
22
|
+
interface JwtSecret {
|
|
23
|
+
publicKeyPem: string;
|
|
24
|
+
version: number;
|
|
25
|
+
}
|
|
26
|
+
|
|
18
27
|
interface AuthPublic {
|
|
19
28
|
enabled?: boolean;
|
|
20
29
|
publicPaths?: string[];
|
|
30
|
+
authType?: 'password' | 'jwt';
|
|
31
|
+
loginUrl?: string;
|
|
32
|
+
groupsPaths?: Record<string, string[]>;
|
|
21
33
|
}
|
|
22
34
|
|
|
23
35
|
/**
|
|
24
|
-
* Resolve active
|
|
36
|
+
* Resolve active auth protection (password OR jwt) for a project.
|
|
37
|
+
*
|
|
38
|
+
* Truth table — the config plane's declared `authType` alone picks the
|
|
39
|
+
* ACTIVE mode; the matching secret key must back it or the gate is off:
|
|
40
|
+
* projectAuthPublic missing OR enabled!==true → no gate (return null)
|
|
41
|
+
* authType==='jwt' AND projectJwtSecret has a publicKeyPem → jwt gate
|
|
42
|
+
* authType==='jwt' AND projectJwtSecret missing/malformed → no gate (return null)
|
|
43
|
+
* authType!=='jwt' (password, incl. absent) AND projectAuthSecret has a hash → password gate
|
|
44
|
+
* authType!=='jwt' AND projectAuthSecret missing/malformed → no gate (return null)
|
|
25
45
|
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
46
|
+
* Coexistence is by design: BOTH secret keys may exist simultaneously
|
|
47
|
+
* during a password→JWT migration (the build writes the new key before
|
|
48
|
+
* flipping authType, and the old key isn't deleted). Only the
|
|
49
|
+
* build-written `authType` decides which secret is consulted — a
|
|
50
|
+
* password hash never satisfies jwt mode and vice versa, so there is no
|
|
51
|
+
* window where a stale secret of the wrong kind is honored.
|
|
30
52
|
*
|
|
31
|
-
*
|
|
32
|
-
* `AuthResolutionError` so middleware returns 503 instead of
|
|
33
|
-
* content without a check. A partial Upstash outage taking
|
|
34
|
-
* while the
|
|
53
|
+
* All three project-level keys fail-CLOSED: if any read throws, this
|
|
54
|
+
* throws `AuthResolutionError` so middleware returns 503 instead of
|
|
55
|
+
* serving gated content without a check. A partial Upstash outage taking
|
|
56
|
+
* down one key while the others are reachable would otherwise create a
|
|
57
|
+
* fail-open window.
|
|
35
58
|
*
|
|
36
|
-
* We intentionally do NOT read `domainAuthSecret` here
|
|
37
|
-
* mirrored on every
|
|
38
|
-
* so a failed mirror write can leave the domain key pointing
|
|
39
|
-
* version. Reading the mirror would then accept old session
|
|
40
|
-
* that host. Always reading the project-level key keeps a
|
|
41
|
-
* of truth and costs
|
|
59
|
+
* We intentionally do NOT read `domainAuthSecret`/`domainJwtSecret` here
|
|
60
|
+
* even though they are mirrored on every write. The dashboard fan-out is
|
|
61
|
+
* best-effort, so a failed mirror write can leave the domain key pointing
|
|
62
|
+
* at a stale version. Reading the mirror would then accept old session
|
|
63
|
+
* cookies on that host. Always reading the project-level key keeps a
|
|
64
|
+
* single source of truth and costs fewer Redis GETs per request on
|
|
65
|
+
* custom domains.
|
|
42
66
|
*
|
|
43
67
|
* When `redis` is null (local dev without Upstash configured), returns
|
|
44
68
|
* null immediately so the CLI dev server doesn't try to gate anything.
|
|
@@ -50,35 +74,33 @@ export class AuthResolutionError extends Error {
|
|
|
50
74
|
}
|
|
51
75
|
}
|
|
52
76
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
let secret: AuthSecret | null;
|
|
59
|
-
let pub: AuthPublic | null;
|
|
60
|
-
|
|
77
|
+
// Every auth read shares one failure contract: any throw becomes an
|
|
78
|
+
// AuthResolutionError so callers fail CLOSED rather than treating an
|
|
79
|
+
// unreachable Redis as "not gated".
|
|
80
|
+
async function readOrFail<T>(read: () => Promise<unknown>): Promise<T | null> {
|
|
61
81
|
try {
|
|
62
|
-
|
|
63
|
-
getProjectAuthSecret(projectSlug),
|
|
64
|
-
getProjectAuthPublic(projectSlug),
|
|
65
|
-
]);
|
|
66
|
-
secret = secretResult as AuthSecret | null;
|
|
67
|
-
pub = publicResult as AuthPublic | null;
|
|
82
|
+
return (await read()) as T | null;
|
|
68
83
|
} catch (e) {
|
|
69
84
|
throw new AuthResolutionError(
|
|
70
85
|
'Failed to read project auth keys via cached-redis',
|
|
71
86
|
e,
|
|
72
87
|
);
|
|
73
88
|
}
|
|
89
|
+
}
|
|
74
90
|
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
91
|
+
export async function resolveAuth(
|
|
92
|
+
projectSlug: string,
|
|
93
|
+
): Promise<ResolvedAuth | null> {
|
|
94
|
+
if (!redis) return null;
|
|
95
|
+
|
|
96
|
+
// Public record FIRST: it decides whether any secret is needed at all, and
|
|
97
|
+
// which one. A non-gated tenant therefore costs one GET per cache miss
|
|
98
|
+
// (not three), and each mode reads only its own secret. The extra RTT on a
|
|
99
|
+
// gated tenant is paid once per 15s cache window, not per request.
|
|
100
|
+
// Fail-closed is unchanged: any throw becomes AuthResolutionError.
|
|
101
|
+
const pub = await readOrFail<AuthPublic>(
|
|
102
|
+
() => getProjectAuthPublic(projectSlug),
|
|
103
|
+
);
|
|
82
104
|
|
|
83
105
|
if (!pub || pub.enabled !== true) {
|
|
84
106
|
return null;
|
|
@@ -92,11 +114,54 @@ export async function resolveAuth(
|
|
|
92
114
|
const publicPaths: string[] = Array.isArray(pub.publicPaths)
|
|
93
115
|
? (pub.publicPaths as string[])
|
|
94
116
|
: [];
|
|
117
|
+
const authType: 'password' | 'jwt' = pub.authType === 'jwt' ? 'jwt' : 'password';
|
|
118
|
+
|
|
119
|
+
if (authType === 'jwt') {
|
|
120
|
+
const jwtSecret = await readOrFail<JwtSecret>(
|
|
121
|
+
() => getProjectJwtSecret(projectSlug),
|
|
122
|
+
);
|
|
123
|
+
if (
|
|
124
|
+
!jwtSecret ||
|
|
125
|
+
typeof jwtSecret.publicKeyPem !== 'string' ||
|
|
126
|
+
jwtSecret.publicKeyPem.length === 0 ||
|
|
127
|
+
typeof jwtSecret.version !== 'number'
|
|
128
|
+
) {
|
|
129
|
+
// authType says jwt but the jwt-own secret isn't there (or is
|
|
130
|
+
// malformed) — gate off. A lingering password hash from a prior
|
|
131
|
+
// mode must NEVER satisfy jwt mode; that would be a half-on gate.
|
|
132
|
+
return null;
|
|
133
|
+
}
|
|
134
|
+
return {
|
|
135
|
+
authType,
|
|
136
|
+
publicKeyPem: jwtSecret.publicKeyPem,
|
|
137
|
+
version: jwtSecret.version,
|
|
138
|
+
publicPaths,
|
|
139
|
+
loginUrl: typeof pub.loginUrl === 'string' ? pub.loginUrl : undefined,
|
|
140
|
+
groupsPaths:
|
|
141
|
+
pub.groupsPaths && typeof pub.groupsPaths === 'object'
|
|
142
|
+
? pub.groupsPaths
|
|
143
|
+
: undefined,
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const secret = await readOrFail<AuthSecret>(
|
|
148
|
+
() => getProjectAuthSecret(projectSlug),
|
|
149
|
+
);
|
|
150
|
+
if (
|
|
151
|
+
!secret ||
|
|
152
|
+
typeof secret.hash !== 'string' ||
|
|
153
|
+
typeof secret.version !== 'number'
|
|
154
|
+
) {
|
|
155
|
+
return null;
|
|
156
|
+
}
|
|
95
157
|
|
|
96
158
|
return {
|
|
159
|
+
authType,
|
|
97
160
|
hash: secret.hash,
|
|
98
161
|
version: secret.version,
|
|
99
162
|
publicPaths,
|
|
163
|
+
loginUrl: undefined,
|
|
164
|
+
groupsPaths: undefined,
|
|
100
165
|
};
|
|
101
166
|
}
|
|
102
167
|
|
|
@@ -117,21 +182,31 @@ export async function resolveAuth(
|
|
|
117
182
|
* fetch mechanism. Therefore: fail OPEN (return false) on Redis error so
|
|
118
183
|
* brownouts don't nullify the CDN-cache latency win.
|
|
119
184
|
*
|
|
120
|
-
* Reads go through cached-redis (getProjectAuthSecret/getProjectAuthPublic
|
|
121
|
-
* the same wrappers resolveAuth uses — so the two
|
|
122
|
-
* entries for these keys instead of each holding
|
|
123
|
-
* does NOT cache fetch errors, so the fail-open
|
|
124
|
-
* the next call rather than pinning `false`.
|
|
185
|
+
* Reads go through cached-redis (getProjectAuthSecret/getProjectAuthPublic/
|
|
186
|
+
* getProjectJwtSecret) — the same wrappers resolveAuth uses — so the two
|
|
187
|
+
* share per-instance cache entries for these keys instead of each holding
|
|
188
|
+
* its own copy. cached-redis does NOT cache fetch errors, so the fail-open
|
|
189
|
+
* path below still retries on the next call rather than pinning `false`.
|
|
190
|
+
*
|
|
191
|
+
* Mirrors resolveAuth's mode binding exactly: a jwt tenant is gated only by
|
|
192
|
+
* its jwt key, a password tenant only by its hash. A lingering hash from a
|
|
193
|
+
* previous mode never counts (resolveAuth ignores it too), so the storage
|
|
194
|
+
* scheme and the edge gate can never disagree about whether a site is open.
|
|
125
195
|
*/
|
|
126
196
|
export async function isProjectGated(slug: string): Promise<boolean> {
|
|
127
197
|
if (!redis) return false;
|
|
128
198
|
|
|
129
199
|
try {
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
200
|
+
// Same public-first ordering as resolveAuth (shared cache entries): a
|
|
201
|
+
// disabled tenant is one GET, and only the mode's own secret is read.
|
|
202
|
+
const pub = await getProjectAuthPublic(slug);
|
|
203
|
+
if (pub?.enabled !== true) return false;
|
|
204
|
+
if (pub.authType === 'jwt') {
|
|
205
|
+
const jwtSecret = await getProjectJwtSecret(slug);
|
|
206
|
+
return typeof jwtSecret?.publicKeyPem === 'string';
|
|
207
|
+
}
|
|
208
|
+
const secret = await getProjectAuthSecret(slug);
|
|
209
|
+
return typeof secret?.hash === 'string';
|
|
135
210
|
} catch {
|
|
136
211
|
// Fail OPEN: this is a read-mechanism choice, not access control.
|
|
137
212
|
// Gating already happened upstream in proxy.ts. Failing closed here
|
|
@@ -79,7 +79,15 @@ const projectMetaCache = new TtlCache<ProjectMeta>({ ttlMs: POSITIVE_TTL_MS, neg
|
|
|
79
79
|
// emergency rotation requires the JD_CACHE_DISABLED kill switch + redeploy.
|
|
80
80
|
// See docs/plans/2026-05-21-redis-in-function-memo-cache.md auto-memory.
|
|
81
81
|
const projectAuthSecretCache = new TtlCache<{ hash: string; version: number }>({ ttlMs: AUTH_TTL_MS, negativeTtlMs: NEGATIVE_TTL_MS });
|
|
82
|
-
|
|
82
|
+
|
|
83
|
+
export interface ProjectAuthPublic {
|
|
84
|
+
enabled: boolean;
|
|
85
|
+
publicPaths: string[];
|
|
86
|
+
authType?: 'password' | 'jwt';
|
|
87
|
+
loginUrl?: string;
|
|
88
|
+
groupsPaths?: Record<string, string[]>;
|
|
89
|
+
}
|
|
90
|
+
const projectAuthPublicCache = new TtlCache<ProjectAuthPublic>({ ttlMs: AUTH_TTL_MS, negativeTtlMs: NEGATIVE_TTL_MS });
|
|
83
91
|
|
|
84
92
|
const inFlight = new Map<string, Promise<unknown>>();
|
|
85
93
|
|
|
@@ -107,6 +115,7 @@ export function _resetForTest(): void {
|
|
|
107
115
|
projectMetaCache.clear();
|
|
108
116
|
projectAuthSecretCache.clear();
|
|
109
117
|
projectAuthPublicCache.clear();
|
|
118
|
+
projectJwtSecretCache.clear();
|
|
110
119
|
inFlight.clear();
|
|
111
120
|
_hits = 0; _misses = 0; _readsSinceLog = 0; _totalReadMs = 0;
|
|
112
121
|
_recent.length = 0; _recentIdx = 0; _recentSum = 0;
|
|
@@ -362,8 +371,24 @@ export async function getProjectAuthSecret(slug: string): Promise<{ hash: string
|
|
|
362
371
|
);
|
|
363
372
|
}
|
|
364
373
|
|
|
365
|
-
export async function getProjectAuthPublic(slug: string): Promise<
|
|
374
|
+
export async function getProjectAuthPublic(slug: string): Promise<ProjectAuthPublic | null> {
|
|
366
375
|
return readThrough(projectAuthPublicCache, `projectAuthPublic:${slug}`, () =>
|
|
367
|
-
redis!.get<
|
|
376
|
+
redis!.get<ProjectAuthPublic>(`projectAuthPublic:${slug}`),
|
|
377
|
+
);
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
// JWT-mode's own secret key — deliberately a SEPARATE cache/key from
|
|
381
|
+
// projectAuthSecret (password), never merged. Coexistence (both keys
|
|
382
|
+
// present during a password→JWT migration) is by design: authType alone
|
|
383
|
+
// (on the public plane) decides which one is consulted.
|
|
384
|
+
export interface ProjectJwtSecret {
|
|
385
|
+
publicKeyPem: string;
|
|
386
|
+
version: number;
|
|
387
|
+
}
|
|
388
|
+
const projectJwtSecretCache = new TtlCache<ProjectJwtSecret>({ ttlMs: AUTH_TTL_MS, negativeTtlMs: NEGATIVE_TTL_MS });
|
|
389
|
+
|
|
390
|
+
export async function getProjectJwtSecret(slug: string): Promise<ProjectJwtSecret | null> {
|
|
391
|
+
return readThrough(projectJwtSecretCache, `projectJwtSecret:${slug}`, () =>
|
|
392
|
+
redis!.get<ProjectJwtSecret>(`projectJwtSecret:${slug}`),
|
|
368
393
|
);
|
|
369
394
|
}
|
|
@@ -846,12 +846,28 @@ export interface PasswordAuthConfig {
|
|
|
846
846
|
private?: string[];
|
|
847
847
|
}
|
|
848
848
|
|
|
849
|
+
/**
|
|
850
|
+
* JWT authentication config. The verification key is generated and stored via
|
|
851
|
+
* the Jamdesk dashboard; this block only carries declarative opt-in, the
|
|
852
|
+
* customer login URL, and public path exceptions.
|
|
853
|
+
*/
|
|
854
|
+
export interface JwtAuthConfig {
|
|
855
|
+
/** Opt in to JWT auth. Site is only gated when this is true AND a signing key has been generated in the dashboard. */
|
|
856
|
+
enabled?: boolean;
|
|
857
|
+
/** Absolute https:// URL of your login flow. Unauthenticated visitors are redirected here with ?redirect=<path>. */
|
|
858
|
+
loginUrl?: string;
|
|
859
|
+
/** Paths or globs that bypass authentication. Supports '*' and '**'. */
|
|
860
|
+
public?: string[];
|
|
861
|
+
}
|
|
862
|
+
|
|
849
863
|
/**
|
|
850
864
|
* Auth configuration for access control. Currently supports shared-password
|
|
851
|
-
* protection; additional mechanisms may be added in
|
|
865
|
+
* protection and JWT authentication; additional mechanisms may be added in
|
|
866
|
+
* future.
|
|
852
867
|
*/
|
|
853
868
|
export interface AuthConfig {
|
|
854
869
|
password?: PasswordAuthConfig;
|
|
870
|
+
jwt?: JwtAuthConfig;
|
|
855
871
|
}
|
|
856
872
|
|
|
857
873
|
/**
|
|
@@ -76,8 +76,62 @@ export function matchPublicPath(
|
|
|
76
76
|
normalized = pathname.slice(5); // '/docs/' → '/' (keep leading slash)
|
|
77
77
|
}
|
|
78
78
|
}
|
|
79
|
+
// The main gate runs on the pretty path BEFORE the `.md` → markdown-export
|
|
80
|
+
// rewrite, so `/changelog.md` must match a public `/changelog` — otherwise
|
|
81
|
+
// the export of a public page over-gates while the HTML page is open.
|
|
82
|
+
// (resolveRequiredGroups strips the same suffix for the group lookup.)
|
|
83
|
+
if (normalized.length > 3 && normalized.endsWith('.md')) {
|
|
84
|
+
normalized = normalized.slice(0, -3);
|
|
85
|
+
}
|
|
79
86
|
|
|
80
87
|
const { literals, regexes } = compilePublicPaths(publicPaths);
|
|
81
88
|
if (literals.has(normalized)) return true;
|
|
82
89
|
return regexes.some((re) => re.test(normalized));
|
|
83
90
|
}
|
|
91
|
+
|
|
92
|
+
const R2_CONTENT_RE = /^\/api\/r2\/[^/]+\/content\/(.+?)(?:\.mdx)?$/;
|
|
93
|
+
// Direct hit on the raw-markdown export route: /api/markdown-export/{slug}/{page}.
|
|
94
|
+
// The captured group is the root-relative page path (the middleware rewrite
|
|
95
|
+
// already stripped any /docs prefix), so — like the /api/r2 branch — this shape
|
|
96
|
+
// carries page content and must resolve group restrictions on it. Without this,
|
|
97
|
+
// a direct external hit would pass the edge gate with no group requirement
|
|
98
|
+
// resolved (the path doesn't look like a page path) and leak group-restricted
|
|
99
|
+
// MDX to any authenticated user (and, gated on the wrong slug, cross-tenant).
|
|
100
|
+
const MARKDOWN_EXPORT_RE = /^\/api\/markdown-export\/[^/]+\/(.+?)(?:\.md)?$/;
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Look up the required groups for a request path against the build-written
|
|
104
|
+
* groupsPaths map (keys are root-relative page slugs). Normalizes every path
|
|
105
|
+
* shape that serves page content: percent-encoding, hostAtDocs /docs prefix,
|
|
106
|
+
* trailing slash, markdown-export .md suffix, /api/r2 raw-MDX paths, and direct
|
|
107
|
+
* /api/markdown-export hits. Returns the required groups, or null when the path
|
|
108
|
+
* carries no restriction.
|
|
109
|
+
*/
|
|
110
|
+
export function resolveRequiredGroups(
|
|
111
|
+
pathname: string,
|
|
112
|
+
groupsPaths: Record<string, string[]>,
|
|
113
|
+
hostAtDocs: boolean,
|
|
114
|
+
): string[] | null {
|
|
115
|
+
let p = pathname;
|
|
116
|
+
try { p = decodeURIComponent(p); } catch { /* keep raw — never throw in the gate */ }
|
|
117
|
+
|
|
118
|
+
const r2 = p.match(R2_CONTENT_RE);
|
|
119
|
+
const mdExport = p.match(MARKDOWN_EXPORT_RE);
|
|
120
|
+
if (r2) {
|
|
121
|
+
p = `/${r2[1]}`;
|
|
122
|
+
} else if (mdExport) {
|
|
123
|
+
// Page path already root-relative (rewrite stripped /docs); no hostAtDocs
|
|
124
|
+
// normalization, matching the /api/r2 branch.
|
|
125
|
+
p = `/${mdExport[1]}`;
|
|
126
|
+
} else {
|
|
127
|
+
if (hostAtDocs) {
|
|
128
|
+
if (p === '/docs') p = '/';
|
|
129
|
+
else if (p.startsWith('/docs/')) p = p.slice(5);
|
|
130
|
+
}
|
|
131
|
+
if (p.endsWith('.md')) p = p.slice(0, -3);
|
|
132
|
+
if (p.length > 1 && p.endsWith('/')) p = p.slice(0, -1);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const required = groupsPaths[p];
|
|
136
|
+
return Array.isArray(required) && required.length > 0 ? required : null;
|
|
137
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { DocsConfig } from './docs-types.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Pure validation for the auth.jwt block. Returns config_error strings.
|
|
5
|
+
* Kept out of validate-config.ts so the CLI's parallel validator and the
|
|
6
|
+
* dashboard can reuse identical logic without dragging in Ajv.
|
|
7
|
+
*/
|
|
8
|
+
export function validateJwtAuth(config: DocsConfig): string[] {
|
|
9
|
+
const auth = config.auth as
|
|
10
|
+
| { password?: { enabled?: unknown }; jwt?: { enabled?: unknown; loginUrl?: unknown; public?: unknown } }
|
|
11
|
+
| undefined;
|
|
12
|
+
const jwt = auth?.jwt;
|
|
13
|
+
if (!jwt) return [];
|
|
14
|
+
|
|
15
|
+
const errors: string[] = [];
|
|
16
|
+
if (auth?.password?.enabled === true && jwt.enabled === true) {
|
|
17
|
+
errors.push('auth.password and auth.jwt cannot both be enabled — choose one authentication method');
|
|
18
|
+
return errors;
|
|
19
|
+
}
|
|
20
|
+
if (jwt.enabled === true) {
|
|
21
|
+
if (typeof jwt.loginUrl !== 'string' || jwt.loginUrl.length === 0) {
|
|
22
|
+
errors.push('auth.jwt.enabled requires auth.jwt.loginUrl (an absolute https:// URL of your login flow)');
|
|
23
|
+
} else {
|
|
24
|
+
let ok = false;
|
|
25
|
+
try {
|
|
26
|
+
ok = new URL(jwt.loginUrl).protocol === 'https:';
|
|
27
|
+
} catch { /* unparseable */ }
|
|
28
|
+
if (!ok) errors.push('auth.jwt.loginUrl must be an absolute https:// URL');
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
if (jwt.public !== undefined) {
|
|
32
|
+
const valid = Array.isArray(jwt.public) &&
|
|
33
|
+
jwt.public.every((p) => typeof p === 'string' && p.startsWith('/'));
|
|
34
|
+
if (!valid) errors.push('auth.jwt.public must be an array of path strings starting with /');
|
|
35
|
+
}
|
|
36
|
+
return errors;
|
|
37
|
+
}
|