Skip to content

Commit de31930

Browse files
devin-ai-integration[bot]kenny@buildwithfern.comkennyderek
authored
feat(docs): add /api/fern-docs/get-jwt endpoint for LLM authentication (#4650)
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Co-authored-by: [email protected] <[email protected]> Co-authored-by: Kenny Derek <[email protected]>
1 parent 7314761 commit de31930

File tree

5 files changed

+139
-15
lines changed

5 files changed

+139
-15
lines changed
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
import { signFernJWT } from "@fern-api/docs-server/auth/FernJWT";
2+
import { getDocsUrlMetadata } from "@fern-api/docs-server/getDocsUrlMetadata";
3+
import { isLocal } from "@fern-api/docs-server/isLocal";
4+
import { isSelfHosted } from "@fern-api/docs-server/isSelfHosted";
5+
import { validateApiKeyBelongsToOrg } from "@fern-api/docs-server/venus/validateApiKeyBelongsToOrg";
6+
import { getDocsDomainEdge } from "@fern-api/docs-server/xfernhost/edge";
7+
import { getAuthEdgeConfig } from "@fern-docs/edge-config";
8+
import { type NextRequest, NextResponse } from "next/server";
9+
10+
export const maxDuration = 10;
11+
12+
export async function GET(req: NextRequest): Promise<NextResponse> {
13+
if (isLocal()) {
14+
return NextResponse.json({ error: "JWT generation is not accessible in local preview mode" }, { status: 400 });
15+
}
16+
17+
if (isSelfHosted()) {
18+
return NextResponse.json(
19+
{ error: "JWT generation is not supported in self-hosted environments" },
20+
{ status: 400 }
21+
);
22+
}
23+
24+
const domain = getDocsDomainEdge(req);
25+
26+
const fernApiKey = req.headers.get("FERN_API_KEY");
27+
if (!fernApiKey) {
28+
return NextResponse.json({ error: "Missing FERN_API_KEY header" }, { status: 401 });
29+
}
30+
31+
const metadata = await getDocsUrlMetadata(domain);
32+
const validation = await validateApiKeyBelongsToOrg(fernApiKey, metadata.org);
33+
34+
if (!validation.valid) {
35+
const status = validation.error?.includes("does not belong") ? 403 : 401;
36+
return NextResponse.json({ error: `Unauthorized: ${validation.error}` }, { status });
37+
}
38+
39+
const authConfig = await getAuthEdgeConfig(domain);
40+
if (!authConfig) {
41+
return NextResponse.json({ error: "No authentication configuration found for this domain" }, { status: 500 });
42+
}
43+
44+
if (authConfig.type === "sso" && authConfig.partner === "workos") {
45+
return NextResponse.json(
46+
{
47+
error: "SSO/WorkOS authentication is not supported by this endpoint. This endpoint is for API key-based authentication only."
48+
},
49+
{ status: 400 }
50+
);
51+
}
52+
53+
const secret =
54+
authConfig.type === "basic_token_verification"
55+
? authConfig.secret
56+
: authConfig.type === "oauth2"
57+
? process.env.OAUTH_JWT_SECRET
58+
: undefined;
59+
60+
if (authConfig.type === "oauth2" && !secret) {
61+
return NextResponse.json(
62+
{ error: "Missing OAUTH_JWT_SECRET configuration for oauth2 authentication" },
63+
{ status: 500 }
64+
);
65+
}
66+
67+
const rolesHeader = req.headers.get("ROLES");
68+
let roles: string[] = [];
69+
if (rolesHeader) {
70+
roles = rolesHeader
71+
.split(",")
72+
.map((role) => role.trim())
73+
.filter((role) => role.length > 0);
74+
}
75+
76+
let issuer: string | undefined;
77+
if (authConfig.type === "basic_token_verification") {
78+
issuer = authConfig.issuer;
79+
} else if (authConfig.type === "oauth2" && "issuer" in authConfig) {
80+
issuer = authConfig.issuer;
81+
}
82+
83+
try {
84+
const fern_token = await signFernJWT(
85+
{
86+
roles,
87+
api_key: fernApiKey
88+
},
89+
{
90+
secret,
91+
issuer
92+
}
93+
);
94+
95+
return NextResponse.json(
96+
{
97+
fern_token,
98+
roles
99+
},
100+
{
101+
status: 200,
102+
headers: {
103+
"Cache-Control": "no-store"
104+
}
105+
}
106+
);
107+
} catch (error) {
108+
console.error("Error generating JWT:", error);
109+
return NextResponse.json({ error: "Failed to generate JWT token" }, { status: 500 });
110+
}
111+
}

packages/fern-docs/bundle/src/app/[host]/[domain]/api/fern-docs/llms-full.txt/route.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ export async function GET(
2020

2121
const path = slugToHref(req.nextUrl.searchParams.get("slug") ?? "");
2222

23-
const fernToken = (await cookies()).get(COOKIE_FERN_TOKEN)?.value;
23+
const fernToken = req.headers.get("FERN_TOKEN") ?? (await cookies()).get(COOKIE_FERN_TOKEN)?.value;
2424

2525
const userAgent = req.headers.get("user-agent");
2626
const acceptHeader = req.headers.get("accept");

packages/fern-docs/bundle/src/app/[host]/[domain]/api/fern-docs/llms.txt/route.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ export async function GET(
4848
});
4949
}
5050

51-
const fernToken = (await cookies()).get(COOKIE_FERN_TOKEN)?.value;
51+
const fernToken = req.headers.get("FERN_TOKEN") ?? (await cookies()).get(COOKIE_FERN_TOKEN)?.value;
5252

5353
const path = slugToHref(req.nextUrl.searchParams.get("slug") ?? "");
5454

packages/fern-docs/bundle/src/app/[host]/[domain]/api/fern-docs/markdown/route.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ export async function GET(
2727

2828
const { host, domain } = await props.params;
2929

30-
const fernToken = (await cookies()).get(COOKIE_FERN_TOKEN)?.value;
30+
const fernToken = req.headers.get("FERN_TOKEN") ?? (await cookies()).get(COOKIE_FERN_TOKEN)?.value;
3131

3232
const path = req.nextUrl.pathname;
3333
const slug = path.replace(MARKDOWN_PATTERN, "");

packages/fern-docs/bundle/src/middleware.ts

Lines changed: 25 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -161,10 +161,27 @@ export const middleware: NextMiddleware = async (request) => {
161161
return rewrite(withDomain(withoutBasepath("/api/fern-docs/")));
162162
}
163163

164+
/**
165+
* If Accept header contains text/plain or text/markdown,
166+
* serve the llms.txt version instead
167+
*/
168+
const acceptHeader = request.headers.get("accept");
169+
const shouldServeLlmsTxt =
170+
acceptHeader &&
171+
(acceptHeader.includes("text/plain") || acceptHeader.includes("text/markdown")) &&
172+
!pathname.endsWith("/llms.txt") &&
173+
!pathname.endsWith("/llms-full.txt") &&
174+
!pathname.match(MARKDOWN_PATTERN);
175+
164176
/**
165177
* Rewrite llms.txt
166178
*/
167-
if (pathname.endsWith("/llms.txt") || pathname.endsWith("/llms-full.txt") || pathname.match(MARKDOWN_PATTERN)) {
179+
if (
180+
shouldServeLlmsTxt ||
181+
pathname.endsWith("/llms.txt") ||
182+
pathname.endsWith("/llms-full.txt") ||
183+
pathname.match(MARKDOWN_PATTERN)
184+
) {
168185
const { getAuthState } = await createGetAuthStateEdge(request, (token) => {
169186
newToken = token;
170187
});
@@ -174,7 +191,13 @@ export const middleware: NextMiddleware = async (request) => {
174191
? `authed:${[...(authState.user.roles ?? ["no_role"])].sort().join(",")}`
175192
: "unauthed:everyone";
176193

177-
if (pathname.endsWith("/llms.txt")) {
194+
if (shouldServeLlmsTxt) {
195+
const slug = removeLeadingSlash(pathname);
196+
return rewrite(withDomain("/api/fern-docs/llms.txt"), {
197+
slug,
198+
authed: rolesValue
199+
});
200+
} else if (pathname.endsWith("/llms.txt")) {
178201
const slug = removeLeadingSlash(withoutEnding(/\/llms\.txt$/));
179202
return rewrite(withDomain("/api/fern-docs/llms.txt"), {
180203
slug,
@@ -226,16 +249,6 @@ export const middleware: NextMiddleware = async (request) => {
226249
return rewrite(withDomain("/api/fern-docs/changelog"), { format, slug });
227250
}
228251

229-
/**
230-
* Content negotiation: If Accept header contains text/plain or text/markdown,
231-
* serve the llms.txt version instead
232-
*/
233-
const acceptHeader = request.headers.get("accept");
234-
if (acceptHeader && (acceptHeader.includes("text/plain") || acceptHeader.includes("text/markdown"))) {
235-
const slug = removeLeadingSlash(pathname);
236-
return rewrite(withDomain("/api/fern-docs/llms.txt"), { slug });
237-
}
238-
239252
/**
240253
* At this point, conform the trailing slash setting or else redirect
241254
*/

0 commit comments

Comments
 (0)