create-ailk 0.29.1 → 0.30.0
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/add-route.js +1 -0
- package/dist/cli.js +15 -1
- package/dist/lib/env-catalog.js +1 -1
- package/dist/lib/scaffold-render-sweep.d.ts +1 -1
- package/dist/lib/scaffold-render-sweep.js +1 -1
- package/dist/module-architecture.js +6 -1
- package/dist/provision-env.d.ts +28 -0
- package/dist/provision-env.js +210 -0
- package/dist/surfaces.js +3 -0
- package/dist/topology.js +4 -0
- package/package.json +3 -3
- package/templates/.env.example +7 -0
- package/templates/apps/api/CLAUDE.md +6 -2
- package/templates/apps/mcp/.env.example +20 -0
- package/templates/apps/mcp/CLAUDE.md +42 -8
- package/templates/apps/web/CLAUDE.md +24 -1
- package/templates/apps/web/app/.well-known/ai-plugin.json/route.ts +9 -1
- package/templates/apps/web/app/.well-known/mcp.json/build-manifest.ts +58 -0
- package/templates/apps/web/app/.well-known/mcp.json/route.ts +16 -0
- package/templates/apps/web/app/.well-known/oauth-authorization-server/route.ts +53 -0
- package/templates/apps/web/app/[locale]/(auth)/__tests__/password-reset.test.tsx +8 -1
- package/templates/apps/web/app/[locale]/(auth)/consent/__tests__/consent-actions.test.ts +56 -0
- package/templates/apps/web/app/[locale]/(auth)/consent/__tests__/page.test.tsx +164 -0
- package/templates/apps/web/app/[locale]/(auth)/consent/consent-form.tsx +132 -0
- package/templates/apps/web/app/[locale]/(auth)/consent/consent.actions.ts +51 -0
- package/templates/apps/web/app/[locale]/(auth)/consent/page.tsx +144 -0
- package/templates/apps/web/app/[locale]/about/__tests__/about-routes.test.tsx +2 -2
- package/templates/apps/web/app/api/auth/hub/continuation.ts +8 -3
- package/templates/apps/web/app/api/auth/mcp/__tests__/continuation.test.ts +92 -0
- package/templates/apps/web/app/api/auth/mcp/continuation.ts +89 -0
- package/templates/apps/web/app/api/auth/mcp/continue/[ref]/__tests__/route.test.ts +59 -0
- package/templates/apps/web/app/api/auth/mcp/continue/[ref]/route.ts +41 -0
- package/templates/apps/web/app/llms.txt/route.ts +10 -0
- package/templates/apps/web/app/openapi.json/__tests__/route.test.ts +40 -0
- package/templates/apps/web/app/openapi.json/route.ts +4 -0
- package/templates/apps/web/app/robots.txt/route.ts +18 -1
- package/templates/apps/web/app/sitemap.xml/route.ts +10 -0
- package/templates/apps/web/auth.ts +184 -7
- package/templates/apps/web/lib/__tests__/auth-routes.test.ts +2 -1
- package/templates/apps/web/lib/auth-routes.ts +5 -3
- package/templates/apps/web/lib/mcp-oauth.ts +702 -0
- package/templates/apps/web/messages/en.json +42 -13
- package/templates/database/seed.ts +6 -0
- package/versions.json +48 -48
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
"use client"; // the workspace choice decides which scopes show and whether Allow is enabled
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The consent page's client island (#10357 D6): the workspace radio list, the
|
|
5
|
+
* chosen workspace's offered scopes as checkboxes — none ticked — and Allow,
|
|
6
|
+
* disabled until a workspace is chosen. Deliberate selection is the defence
|
|
7
|
+
* against a phished authorization (SEC-10357-11, -13). Strings arrive
|
|
8
|
+
* translated from the server page.
|
|
9
|
+
*/
|
|
10
|
+
import {
|
|
11
|
+
Button,
|
|
12
|
+
Checkbox,
|
|
13
|
+
Input,
|
|
14
|
+
Label,
|
|
15
|
+
RadioGroup,
|
|
16
|
+
RadioGroupItem,
|
|
17
|
+
Text,
|
|
18
|
+
} from "@working-theory/ui/primitives";
|
|
19
|
+
import type { ReactElement } from "react";
|
|
20
|
+
import { useState } from "react";
|
|
21
|
+
|
|
22
|
+
import { decideConsent } from "./consent.actions";
|
|
23
|
+
|
|
24
|
+
export interface ConsentWorkspace {
|
|
25
|
+
id: string;
|
|
26
|
+
name: string;
|
|
27
|
+
organizationName: string;
|
|
28
|
+
/** Each offered scope with its label. */
|
|
29
|
+
offered: Array<{ scope: string; permission: string; label: string }>;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface ConsentFormText {
|
|
33
|
+
workspaceLegend: string;
|
|
34
|
+
scopesLegend: string;
|
|
35
|
+
memberReads: string;
|
|
36
|
+
noScopes: string;
|
|
37
|
+
allow: string;
|
|
38
|
+
deny: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface ConsentFormProps {
|
|
42
|
+
consentCode: string;
|
|
43
|
+
locale: string;
|
|
44
|
+
workspaces: ConsentWorkspace[];
|
|
45
|
+
text: ConsentFormText;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function ConsentForm({ consentCode, locale, workspaces, text }: ConsentFormProps): ReactElement {
|
|
49
|
+
const [workspaceId, setWorkspaceId] = useState<string>(
|
|
50
|
+
workspaces.length === 1 ? (workspaces[0]?.id ?? "") : "",
|
|
51
|
+
);
|
|
52
|
+
const chosen = workspaces.find((w) => w.id === workspaceId);
|
|
53
|
+
|
|
54
|
+
return (
|
|
55
|
+
<form action={decideConsent} className="flex flex-col gap-6" data-testid="mcp-consent-form">
|
|
56
|
+
<Input type="hidden" name="consent_code" value={consentCode} />
|
|
57
|
+
<Input type="hidden" name="locale" value={locale} />
|
|
58
|
+
{workspaces.length > 0 ? (
|
|
59
|
+
<div className="flex flex-col gap-3">
|
|
60
|
+
<Text as="p" variant="body-sm" className="font-semibold">
|
|
61
|
+
{text.workspaceLegend}
|
|
62
|
+
</Text>
|
|
63
|
+
<RadioGroup
|
|
64
|
+
name="workspace_id"
|
|
65
|
+
value={workspaceId}
|
|
66
|
+
onValueChange={setWorkspaceId}
|
|
67
|
+
aria-label={text.workspaceLegend}
|
|
68
|
+
>
|
|
69
|
+
{workspaces.map((w) => (
|
|
70
|
+
<div key={w.id} className="flex min-h-11 items-center gap-3">
|
|
71
|
+
<RadioGroupItem
|
|
72
|
+
id={`mcp-consent-ws-${w.id}`}
|
|
73
|
+
value={w.id}
|
|
74
|
+
data-testid={`mcp-consent-workspace-${w.id}`}
|
|
75
|
+
/>
|
|
76
|
+
<Label htmlFor={`mcp-consent-ws-${w.id}`}>
|
|
77
|
+
{/* i18n-allow: a separator between two data values */}
|
|
78
|
+
{w.name} · {w.organizationName}
|
|
79
|
+
</Label>
|
|
80
|
+
</div>
|
|
81
|
+
))}
|
|
82
|
+
</RadioGroup>
|
|
83
|
+
</div>
|
|
84
|
+
) : null}
|
|
85
|
+
{chosen ? (
|
|
86
|
+
<div className="flex flex-col gap-3">
|
|
87
|
+
<Text as="p" variant="body-sm" className="font-semibold">
|
|
88
|
+
{text.scopesLegend}
|
|
89
|
+
</Text>
|
|
90
|
+
{chosen.offered.length === 0 ? (
|
|
91
|
+
<Text as="p" variant="body-sm" tone="muted">
|
|
92
|
+
{text.noScopes}
|
|
93
|
+
</Text>
|
|
94
|
+
) : (
|
|
95
|
+
chosen.offered.map((o) => (
|
|
96
|
+
<div key={o.scope} className="flex min-h-11 items-center gap-3">
|
|
97
|
+
<Checkbox
|
|
98
|
+
id={`mcp-consent-scope-${o.permission}`}
|
|
99
|
+
name="scopes"
|
|
100
|
+
value={o.scope}
|
|
101
|
+
defaultChecked={false}
|
|
102
|
+
data-testid={`mcp-consent-scope-${o.permission}`}
|
|
103
|
+
/>
|
|
104
|
+
<Label htmlFor={`mcp-consent-scope-${o.permission}`}>{o.label}</Label>
|
|
105
|
+
</div>
|
|
106
|
+
))
|
|
107
|
+
)}
|
|
108
|
+
<Text as="p" variant="body-sm" tone="muted" data-testid="mcp-consent-member-reads">
|
|
109
|
+
{text.memberReads}
|
|
110
|
+
</Text>
|
|
111
|
+
</div>
|
|
112
|
+
) : null}
|
|
113
|
+
<div className="flex flex-wrap gap-3">
|
|
114
|
+
{workspaces.length > 0 ? (
|
|
115
|
+
<Button
|
|
116
|
+
type="submit"
|
|
117
|
+
name="decision"
|
|
118
|
+
value="allow"
|
|
119
|
+
size="big"
|
|
120
|
+
disabled={!chosen}
|
|
121
|
+
data-testid="mcp-consent-allow"
|
|
122
|
+
>
|
|
123
|
+
{text.allow}
|
|
124
|
+
</Button>
|
|
125
|
+
) : null}
|
|
126
|
+
<Button type="submit" name="decision" value="deny" variant="secondary" size="big" data-testid="mcp-consent-deny">
|
|
127
|
+
{text.deny}
|
|
128
|
+
</Button>
|
|
129
|
+
</div>
|
|
130
|
+
</form>
|
|
131
|
+
);
|
|
132
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"use server";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* `decideConsent` — the consent page's Allow and Deny (#10357 D6).
|
|
5
|
+
*
|
|
6
|
+
* Hands the person's choice to AILK's `POST /mcp/consent` in-process
|
|
7
|
+
* (`auth.api.mcpConsent`), which checks the session, the signed consent
|
|
8
|
+
* cookie and the role bound on every scope, and answers the client's own
|
|
9
|
+
* callback URL. Next's Server Action origin check plus that cookie match make
|
|
10
|
+
* a cross-site submit fail. A refusal returns to the page, which then renders
|
|
11
|
+
* what is still true.
|
|
12
|
+
*/
|
|
13
|
+
import { localeHref } from "@working-theory/ui";
|
|
14
|
+
import { headers } from "next/headers";
|
|
15
|
+
import { redirect } from "next/navigation";
|
|
16
|
+
|
|
17
|
+
import { auth } from "../../../../auth";
|
|
18
|
+
|
|
19
|
+
const LOCALE_PATTERN = /^[a-z]{2,3}(?:-[A-Za-z0-9]{2,8})*$/;
|
|
20
|
+
|
|
21
|
+
export async function decideConsent(formData: FormData): Promise<void> {
|
|
22
|
+
const field = (name: string): string => {
|
|
23
|
+
const value = formData.get(name);
|
|
24
|
+
return typeof value === "string" ? value : "";
|
|
25
|
+
};
|
|
26
|
+
const consentCode = field("consent_code");
|
|
27
|
+
// The fallback redirect is built from it, so only a locale-shaped value
|
|
28
|
+
// counts: `/evil.example` would make `//evil.example/…`.
|
|
29
|
+
const locale = LOCALE_PATTERN.test(field("locale")) ? field("locale") : "en";
|
|
30
|
+
const accept = formData.get("decision") === "allow";
|
|
31
|
+
const workspaceId = formData.get("workspace_id");
|
|
32
|
+
const scopes = formData.getAll("scopes").filter((s): s is string => typeof s === "string");
|
|
33
|
+
|
|
34
|
+
let redirectURI: string | null;
|
|
35
|
+
try {
|
|
36
|
+
const answer = await auth.api.mcpConsent({
|
|
37
|
+
headers: await headers(),
|
|
38
|
+
body: {
|
|
39
|
+
consent_code: consentCode,
|
|
40
|
+
accept,
|
|
41
|
+
...(accept && typeof workspaceId === "string" ? { workspace_id: workspaceId, scopes } : {}),
|
|
42
|
+
},
|
|
43
|
+
});
|
|
44
|
+
redirectURI = answer.redirectURI;
|
|
45
|
+
} catch {
|
|
46
|
+
redirectURI = null;
|
|
47
|
+
}
|
|
48
|
+
redirect(
|
|
49
|
+
redirectURI ?? localeHref(locale, `/consent?consent_code=${encodeURIComponent(consentCode)}`),
|
|
50
|
+
);
|
|
51
|
+
}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `/[locale]/consent` — the MCP consent step (#10357 D6).
|
|
3
|
+
*
|
|
4
|
+
* The `mcp` plugin sends every authorization here (AILK's before-hook forces
|
|
5
|
+
* `prompt=consent`, D2) with a `consent_code` and a signed cookie. The page
|
|
6
|
+
* reads ONLY `consent_code` from its query — never `client_id` or `scope`,
|
|
7
|
+
* which the client controls — and asks AILK's `GET /mcp/consent-details`
|
|
8
|
+
* in-process. That endpoint checks the session, the cookie and that the code
|
|
9
|
+
* was issued to this person, and answers the client's self-claimed name,
|
|
10
|
+
* where the code will go, and each workspace with the scopes the person's
|
|
11
|
+
* role there may approve. Any refusal, or no session, renders the
|
|
12
|
+
* `unavailable` line and no form.
|
|
13
|
+
*
|
|
14
|
+
* It names who is signed in (with a sign-out back to this page), names the
|
|
15
|
+
* client as the client names itself — an unchecked claim — and its redirect
|
|
16
|
+
* origin, then hands the workspaces to the `ConsentForm` island. `noindex`,
|
|
17
|
+
* `referrer: same-origin` (the `account/connect` precedent). Framed by
|
|
18
|
+
* `AuthShell` like sign-in: `"consent"` is an `AUTH_SEGMENTS` entry.
|
|
19
|
+
*/
|
|
20
|
+
import { localeHref } from "@working-theory/ui";
|
|
21
|
+
import { Button, Heading, Text } from "@working-theory/ui/primitives";
|
|
22
|
+
import { SCOPE_GRANT_MAP, type McpScope } from "@working-theory/validation";
|
|
23
|
+
import type { Metadata } from "next";
|
|
24
|
+
import { headers } from "next/headers";
|
|
25
|
+
import { getTranslations } from "next-intl/server";
|
|
26
|
+
import type { ReactElement } from "react";
|
|
27
|
+
|
|
28
|
+
import { auth } from "../../../../auth";
|
|
29
|
+
import { SignOutTrigger } from "../../../../components/sign-out-trigger";
|
|
30
|
+
|
|
31
|
+
import { ConsentForm, type ConsentWorkspace } from "./consent-form";
|
|
32
|
+
|
|
33
|
+
interface Props {
|
|
34
|
+
params: Promise<{ locale: string }>;
|
|
35
|
+
searchParams: Promise<{ consent_code?: string | string[] }>;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
interface ConsentDetails {
|
|
39
|
+
client: { name: string; redirectOrigin: string };
|
|
40
|
+
workspaces: Array<{ id: string; name: string; organizationName: string; offered: McpScope[] }>;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export async function generateMetadata({ params }: Props): Promise<Metadata> {
|
|
44
|
+
const { locale } = await params;
|
|
45
|
+
const t = await getTranslations({ locale, namespace: "mcpConsent" });
|
|
46
|
+
return {
|
|
47
|
+
title: t("metaTitle"),
|
|
48
|
+
robots: { index: false, follow: false },
|
|
49
|
+
referrer: "same-origin",
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** The details and the signed-in email, or `null` on any refusal. */
|
|
54
|
+
async function readDetails(consentCode: string): Promise<{ details: ConsentDetails; email: string } | null> {
|
|
55
|
+
try {
|
|
56
|
+
const requestHeaders = await headers();
|
|
57
|
+
const session = await auth.api.getSession({ headers: requestHeaders });
|
|
58
|
+
if (!session) return null;
|
|
59
|
+
const details = (await auth.api.mcpConsentDetails({
|
|
60
|
+
headers: requestHeaders,
|
|
61
|
+
query: { consent_code: consentCode },
|
|
62
|
+
})) as ConsentDetails;
|
|
63
|
+
return { details, email: session.user.email };
|
|
64
|
+
} catch {
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export default async function ConsentPage({ params, searchParams }: Props): Promise<ReactElement> {
|
|
70
|
+
const { locale } = await params;
|
|
71
|
+
const { consent_code: raw } = await searchParams;
|
|
72
|
+
const t = await getTranslations({ locale, namespace: "mcpConsent" });
|
|
73
|
+
const consentCode = typeof raw === "string" ? raw : "";
|
|
74
|
+
const read = consentCode ? await readDetails(consentCode) : null;
|
|
75
|
+
|
|
76
|
+
if (!read) {
|
|
77
|
+
return (
|
|
78
|
+
<div className="flex flex-col gap-4" data-testid="mcp-consent-unavailable">
|
|
79
|
+
<Heading level={1} visual={3}>
|
|
80
|
+
{t("heading")}
|
|
81
|
+
</Heading>
|
|
82
|
+
<Text as="p" variant="body-sm" tone="muted">
|
|
83
|
+
{t("unavailable")}
|
|
84
|
+
</Text>
|
|
85
|
+
</div>
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const { details, email } = read;
|
|
90
|
+
const here = localeHref(locale, `/consent?consent_code=${encodeURIComponent(consentCode)}`);
|
|
91
|
+
const workspaces: ConsentWorkspace[] = details.workspaces.map((w) => ({
|
|
92
|
+
id: w.id,
|
|
93
|
+
name: w.name,
|
|
94
|
+
organizationName: w.organizationName,
|
|
95
|
+
offered: w.offered.map((scope) => {
|
|
96
|
+
const permission = SCOPE_GRANT_MAP[scope];
|
|
97
|
+
return { scope, permission, label: t(`scopes.${permission}`) };
|
|
98
|
+
}),
|
|
99
|
+
}));
|
|
100
|
+
|
|
101
|
+
return (
|
|
102
|
+
<div className="flex flex-col gap-6" data-testid="mcp-consent">
|
|
103
|
+
<div className="flex flex-col gap-2">
|
|
104
|
+
<Heading level={1} visual={3}>
|
|
105
|
+
{t("heading")}
|
|
106
|
+
</Heading>
|
|
107
|
+
<Text as="p" variant="body-sm" data-testid="mcp-consent-signed-in">
|
|
108
|
+
{t("signedInAs", { email })}
|
|
109
|
+
</Text>
|
|
110
|
+
<SignOutTrigger homeHref={here}>
|
|
111
|
+
<Button type="button" variant="link" data-testid="mcp-consent-sign-out">
|
|
112
|
+
{t("signOut")}
|
|
113
|
+
</Button>
|
|
114
|
+
</SignOutTrigger>
|
|
115
|
+
</div>
|
|
116
|
+
<div className="flex flex-col gap-2">
|
|
117
|
+
<Text as="p" variant="body-sm" data-testid="mcp-consent-client">
|
|
118
|
+
{t("clientClaim", { name: details.client.name })}
|
|
119
|
+
</Text>
|
|
120
|
+
<Text as="p" variant="body-sm" tone="muted" data-testid="mcp-consent-redirect-origin">
|
|
121
|
+
{t("returnsTo", { origin: details.client.redirectOrigin })}
|
|
122
|
+
</Text>
|
|
123
|
+
</div>
|
|
124
|
+
{workspaces.length === 0 ? (
|
|
125
|
+
<Text as="p" variant="body-sm" tone="muted" data-testid="mcp-consent-no-workspace">
|
|
126
|
+
{t("noWorkspace")}
|
|
127
|
+
</Text>
|
|
128
|
+
) : null}
|
|
129
|
+
<ConsentForm
|
|
130
|
+
consentCode={consentCode}
|
|
131
|
+
locale={locale}
|
|
132
|
+
workspaces={workspaces}
|
|
133
|
+
text={{
|
|
134
|
+
workspaceLegend: t("workspaceLegend"),
|
|
135
|
+
scopesLegend: t("scopesLegend"),
|
|
136
|
+
memberReads: t("memberReads"),
|
|
137
|
+
noScopes: t("noScopes"),
|
|
138
|
+
allow: t("allow"),
|
|
139
|
+
deny: t("deny"),
|
|
140
|
+
}}
|
|
141
|
+
/>
|
|
142
|
+
</div>
|
|
143
|
+
);
|
|
144
|
+
}
|
|
@@ -329,7 +329,7 @@ describe("the sitemap and llms.txt advertise /about/<slug>, never a bare /<slug>
|
|
|
329
329
|
|
|
330
330
|
it("sitemap.xml: one <loc> ending in /about/sam-henry, none ending in a bare /sam-henry", async () => {
|
|
331
331
|
const stubAdapter = makeStubAdapter();
|
|
332
|
-
const GET = createSitemapHandler({ getContentAdapter: () => stubAdapter, getDocsPages: () => [] });
|
|
332
|
+
const GET = createSitemapHandler({ getContentAdapter: () => stubAdapter, getDocsPages: () => [], discoverable: async () => true });
|
|
333
333
|
const response = await GET();
|
|
334
334
|
const text = await response.text();
|
|
335
335
|
const locs = Array.from(text.matchAll(/<loc>([^<]*)<\/loc>/g)).map(
|
|
@@ -341,7 +341,7 @@ describe("the sitemap and llms.txt advertise /about/<slug>, never a bare /<slug>
|
|
|
341
341
|
|
|
342
342
|
it("llms.txt: names a URL ending in /about/sam-henry", async () => {
|
|
343
343
|
const stubAdapter = makeStubAdapter();
|
|
344
|
-
const GET = createLlmsHandler({ getContentAdapter: () => stubAdapter, getDocsPages: () => [] });
|
|
344
|
+
const GET = createLlmsHandler({ getContentAdapter: () => stubAdapter, getDocsPages: () => [], discoverable: async () => true });
|
|
345
345
|
const response = await GET();
|
|
346
346
|
const text = await response.text();
|
|
347
347
|
expect(text).toContain("URL: https://site.example/about/sam-henry");
|
|
@@ -30,7 +30,10 @@ export const CONTINUATION_REF_MAX = 4096;
|
|
|
30
30
|
*/
|
|
31
31
|
export const CONTINUATION_TTL_SECONDS = 600;
|
|
32
32
|
|
|
33
|
-
/**
|
|
33
|
+
/**
|
|
34
|
+
* A fixed name: a value sealed for a cookie never opens here, nor the reverse.
|
|
35
|
+
* The MCP sign-in leg seals under its own name (`../mcp/continuation.ts`).
|
|
36
|
+
*/
|
|
34
37
|
const AAD = "hub-continue";
|
|
35
38
|
|
|
36
39
|
const BASE64URL = /^[A-Za-z0-9_-]+$/;
|
|
@@ -40,12 +43,13 @@ export function mintContinuation(
|
|
|
40
43
|
search: string,
|
|
41
44
|
secret: string,
|
|
42
45
|
now: number = Date.now(),
|
|
46
|
+
name: string = AAD,
|
|
43
47
|
): string {
|
|
44
48
|
return seal(
|
|
45
49
|
{ v: 1, q: search, exp: now + CONTINUATION_TTL_SECONDS * 1000 },
|
|
46
50
|
secret,
|
|
47
51
|
"continue",
|
|
48
|
-
|
|
52
|
+
name,
|
|
49
53
|
);
|
|
50
54
|
}
|
|
51
55
|
|
|
@@ -62,9 +66,10 @@ export function openContinuation(
|
|
|
62
66
|
ref: string,
|
|
63
67
|
secret: string,
|
|
64
68
|
now: number = Date.now(),
|
|
69
|
+
name: string = AAD,
|
|
65
70
|
): string | null {
|
|
66
71
|
if (ref.length > CONTINUATION_REF_MAX || !BASE64URL.test(ref)) return null;
|
|
67
|
-
const value = unseal(ref, secret, "continue",
|
|
72
|
+
const value = unseal(ref, secret, "continue", name);
|
|
68
73
|
if (typeof value !== "object" || value === null) return null;
|
|
69
74
|
const { v, q, exp } = value as Record<string, unknown>;
|
|
70
75
|
if (v !== 1 || typeof q !== "string" || q === "") return null;
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @jest-environment node
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The MCP sign-in leg's continuation (#10479): the hub's sealed ref, under a
|
|
7
|
+
* name of its own. Pure functions over `seal.ts`; no mocks.
|
|
8
|
+
*/
|
|
9
|
+
import { mintContinuation } from "../../hub/continuation";
|
|
10
|
+
import {
|
|
11
|
+
MCP_CONTINUE_PATH,
|
|
12
|
+
mintMcpContinuation,
|
|
13
|
+
openMcpContinuation,
|
|
14
|
+
sealMcpCallbacks,
|
|
15
|
+
} from "../continuation";
|
|
16
|
+
|
|
17
|
+
const SECRET = "k".repeat(43);
|
|
18
|
+
const T = 1_790_000_000_000;
|
|
19
|
+
|
|
20
|
+
const Q = new URLSearchParams({
|
|
21
|
+
response_type: "code",
|
|
22
|
+
client_id: "mcp-client",
|
|
23
|
+
redirect_uri: "http://127.0.0.1:53682/callback",
|
|
24
|
+
state: "s".repeat(43),
|
|
25
|
+
code_challenge: "c".repeat(43),
|
|
26
|
+
code_challenge_method: "S256",
|
|
27
|
+
}).toString();
|
|
28
|
+
|
|
29
|
+
it("pins the continue path", () => {
|
|
30
|
+
expect(MCP_CONTINUE_PATH).toBe("/api/auth/mcp/continue");
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("round-trips the authorize query, scheme-bearing redirect_uri included", () => {
|
|
34
|
+
expect(openMcpContinuation(mintMcpContinuation(Q, SECRET, T), SECRET, T + 1_000)).toBe(Q);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("mints a ref no decode can change: base64url only, so no ':', '%', '?', '&' or '='", () => {
|
|
38
|
+
expect(mintMcpContinuation(Q, SECRET, T)).toMatch(/^[A-Za-z0-9_-]+$/);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("expires at 600 s", () => {
|
|
42
|
+
expect(openMcpContinuation(mintMcpContinuation(Q, SECRET, T), SECRET, T + 600_000)).toBeNull();
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("never opens a hub ref, nor under another secret", () => {
|
|
46
|
+
expect(openMcpContinuation(mintContinuation(Q, SECRET, T), SECRET, T)).toBeNull();
|
|
47
|
+
expect(openMcpContinuation(mintMcpContinuation(Q, SECRET, T), "x".repeat(43), T)).toBeNull();
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
describe("sealMcpCallbacks — the sign-in body's MCP resume, sealed", () => {
|
|
51
|
+
const next = `/api/auth/mcp/authorize?${Q}`;
|
|
52
|
+
const refOf = (path: string | undefined) =>
|
|
53
|
+
/^\/api\/auth\/mcp\/continue\/([A-Za-z0-9_-]+)$/.exec(path ?? "")?.[1] ?? "";
|
|
54
|
+
|
|
55
|
+
it("seals callbackURL and the next inside the sign-in page's errorCallbackURL; both open to the query", () => {
|
|
56
|
+
const sealed = sealMcpCallbacks(
|
|
57
|
+
{ callbackURL: next, errorCallbackURL: `/de/sign-in?next=${encodeURIComponent(next)}` },
|
|
58
|
+
SECRET,
|
|
59
|
+
);
|
|
60
|
+
expect(openMcpContinuation(refOf(sealed?.callbackURL), SECRET)).toBe(Q);
|
|
61
|
+
const error = new URL(sealed?.errorCallbackURL ?? "", "http://x.invalid");
|
|
62
|
+
expect(error.pathname).toBe("/de/sign-in");
|
|
63
|
+
expect(openMcpContinuation(refOf(error.searchParams.get("next") ?? ""), SECRET)).toBe(Q);
|
|
64
|
+
// No decode of either value can produce a ':'.
|
|
65
|
+
expect(decodeURIComponent(decodeURIComponent(sealed?.errorCallbackURL ?? ""))).not.toContain(":");
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it.each([
|
|
69
|
+
["no callbacks", {}],
|
|
70
|
+
["an ordinary next", { callbackURL: "/account", errorCallbackURL: "/sign-in?next=%2Faccount" }],
|
|
71
|
+
["the hub's own authorize path", { callbackURL: `/api/auth/hub/authorize?${Q}` }],
|
|
72
|
+
["a foreign errorCallbackURL", { errorCallbackURL: `https://evil.example/?next=${encodeURIComponent(next)}` }],
|
|
73
|
+
["a non-string callbackURL", { callbackURL: [next] }],
|
|
74
|
+
// Security review: URL's dot-segment collapse would emit //evil.example.
|
|
75
|
+
["a dot-segment errorCallbackURL", { errorCallbackURL: `/.//evil.example?next=${encodeURIComponent(next)}` }],
|
|
76
|
+
["an errorCallbackURL on another page", { errorCallbackURL: `/account?next=${encodeURIComponent(next)}` }],
|
|
77
|
+
["an errorCallbackURL with a second key", { errorCallbackURL: `/sign-in?next=${encodeURIComponent(next)}&x=1` }],
|
|
78
|
+
["a next holding CR/LF", { callbackURL: `${next}%0D%0A`.replace("%0D%0A", "\r\n") }],
|
|
79
|
+
["an undecodable next", { errorCallbackURL: "/sign-in?next=%E0%A4%A" }],
|
|
80
|
+
])("leaves %s alone (null)", (_name, body) => {
|
|
81
|
+
expect(sealMcpCallbacks(body, SECRET)).toBeNull();
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("seals the sign-up page's errorCallbackURL the same way", () => {
|
|
85
|
+
const sealed = sealMcpCallbacks({ errorCallbackURL: `/sign-up?next=${encodeURIComponent(next)}` }, SECRET);
|
|
86
|
+
expect(sealed?.errorCallbackURL).toMatch(/^\/sign-up\?next=%2Fapi%2Fauth%2Fmcp%2Fcontinue%2F[A-Za-z0-9_-]+$/);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("leaves a query too long to seal under the continue route's cap as it was", () => {
|
|
90
|
+
expect(sealMcpCallbacks({ callbackURL: `${next}&state=${"s".repeat(4096)}` }, SECRET)).toBeNull();
|
|
91
|
+
});
|
|
92
|
+
});
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The MCP sign-in leg's continuation (#10479).
|
|
3
|
+
*
|
|
4
|
+
* The plugin's `loginPage` (`lib/mcp-oauth.ts`, `/mcp/sign-in`) hands
|
|
5
|
+
* `/sign-in` a `next` of `/api/auth/mcp/authorize?<query>`, and the page sends
|
|
6
|
+
* it on as the sign-in's `callbackURL`. An MCP client's query carries a
|
|
7
|
+
* scheme-bearing `redirect_uri` (`http://127.0.0.1:PORT/callback`, any
|
|
8
|
+
* `https://` callback), and Better Auth's magic-link verify decodes
|
|
9
|
+
* `callbackURL` and checks it as a relative path whose query admits no `:` —
|
|
10
|
+
* so the emailed link answered 403 INVALID_CALLBACK_URL and the person never
|
|
11
|
+
* reached `/consent`.
|
|
12
|
+
*
|
|
13
|
+
* `auth.ts` therefore rewrites such a callback, as a sign-in request arrives,
|
|
14
|
+
* to the same-origin path `/api/auth/mcp/continue/<ref>` — the hub's sealed
|
|
15
|
+
* ref (`../hub/continuation.ts`), under a name of its own so a ref minted for
|
|
16
|
+
* one leg never opens at the other — and the continue route returns the
|
|
17
|
+
* person to `/mcp/authorize`, which forces consent again.
|
|
18
|
+
*/
|
|
19
|
+
import { CONTINUATION_REF_MAX, mintContinuation, openContinuation } from "../hub/continuation";
|
|
20
|
+
|
|
21
|
+
/** The same-origin path a ref is appended to. */
|
|
22
|
+
export const MCP_CONTINUE_PATH = "/api/auth/mcp/continue";
|
|
23
|
+
|
|
24
|
+
/** The resume target the plugin's `loginPage` puts in `next`. */
|
|
25
|
+
const AUTHORIZE_PREFIX = "/api/auth/mcp/authorize?";
|
|
26
|
+
|
|
27
|
+
const NAME = "mcp-continue";
|
|
28
|
+
|
|
29
|
+
const PRINTABLE = /^[\x21-\x7e]+$/;
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* The exact `errorCallbackURL` the sign-in and sign-up pages send:
|
|
33
|
+
* `[/<locale>]/sign-in?next=<encoded>` (or `/sign-up`). Matched on the raw
|
|
34
|
+
* string and rebuilt from its own path bytes — never through `URL`, whose
|
|
35
|
+
* dot-segment collapse would turn an accepted `/.//host` into a
|
|
36
|
+
* protocol-relative `//host` after Better Auth's origin check has run.
|
|
37
|
+
*/
|
|
38
|
+
const SIGN_IN_ERROR_CALLBACK = /^(\/(?:[a-z]{2}(?:-[A-Za-z]{2,4})?\/)?sign-(?:in|up))\?next=([^&#]*)$/;
|
|
39
|
+
|
|
40
|
+
/** `search` (the authorize query, no leading `?`) as a sealed ref. */
|
|
41
|
+
export function mintMcpContinuation(search: string, secret: string, now: number = Date.now()): string {
|
|
42
|
+
return mintContinuation(search, secret, now, NAME);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** The authorize query a ref holds, or `null` (see `openContinuation`). */
|
|
46
|
+
export function openMcpContinuation(ref: string, secret: string, now: number = Date.now()): string | null {
|
|
47
|
+
return openContinuation(ref, secret, now, NAME);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** `path` as a continue path when it resumes an MCP authorization, else unchanged. */
|
|
51
|
+
function sealed(path: string, secret: string): string {
|
|
52
|
+
// Printable ASCII only, as the origin check admits: nothing sealed may hold
|
|
53
|
+
// a control or non-Latin-1 character the continue route's Location refuses.
|
|
54
|
+
if (!path.startsWith(AUTHORIZE_PREFIX) || !PRINTABLE.test(path)) return path;
|
|
55
|
+
const ref = mintMcpContinuation(path.slice(AUTHORIZE_PREFIX.length), secret);
|
|
56
|
+
// Past the cap the continue route would not open it: leave the path as it was.
|
|
57
|
+
return ref.length > CONTINUATION_REF_MAX ? path : `${MCP_CONTINUE_PATH}/${ref}`;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* The sign-in body's callbacks with every MCP resume sealed, or `null` when
|
|
62
|
+
* none is: `callbackURL` itself, and the `next` the sign-in page puts inside
|
|
63
|
+
* `errorCallbackURL` (`SIGN_IN_ERROR_CALLBACK`), whose decoded form holds the
|
|
64
|
+
* same query. Every other value — any other shape included — passes through
|
|
65
|
+
* unchanged, so this hook can only ever write a same-origin continue path.
|
|
66
|
+
*/
|
|
67
|
+
export function sealMcpCallbacks(
|
|
68
|
+
body: { callbackURL?: unknown; errorCallbackURL?: unknown },
|
|
69
|
+
secret: string,
|
|
70
|
+
): { callbackURL?: string; errorCallbackURL?: string } | null {
|
|
71
|
+
const out: { callbackURL?: string; errorCallbackURL?: string } = {};
|
|
72
|
+
if (typeof body.callbackURL === "string") {
|
|
73
|
+
const callbackURL = sealed(body.callbackURL, secret);
|
|
74
|
+
if (callbackURL !== body.callbackURL) out.callbackURL = callbackURL;
|
|
75
|
+
}
|
|
76
|
+
const match = typeof body.errorCallbackURL === "string" ? SIGN_IN_ERROR_CALLBACK.exec(body.errorCallbackURL) : null;
|
|
77
|
+
if (match) {
|
|
78
|
+
const [, page, encoded = ""] = match;
|
|
79
|
+
let next: string;
|
|
80
|
+
try {
|
|
81
|
+
next = decodeURIComponent(encoded);
|
|
82
|
+
} catch {
|
|
83
|
+
next = "";
|
|
84
|
+
}
|
|
85
|
+
const continued = sealed(next, secret);
|
|
86
|
+
if (continued !== next) out.errorCallbackURL = `${page}?next=${encodeURIComponent(continued)}`;
|
|
87
|
+
}
|
|
88
|
+
return Object.keys(out).length > 0 ? out : null;
|
|
89
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @jest-environment node
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The MCP sign-in leg's continue step (#10479): a ref returns the person to
|
|
7
|
+
* `/api/auth/mcp/authorize` with the original query, and nowhere else.
|
|
8
|
+
*/
|
|
9
|
+
import { mintContinuation } from "../../../../hub/continuation";
|
|
10
|
+
import { mintMcpContinuation } from "../../../continuation";
|
|
11
|
+
import { GET } from "../route";
|
|
12
|
+
|
|
13
|
+
const SITE = "https://site.example.com";
|
|
14
|
+
const SECRET = "flow-secret-0123456789abcdef0123456789abcdef";
|
|
15
|
+
const ORIGINAL_ENV = process.env;
|
|
16
|
+
|
|
17
|
+
const QUERY = new URLSearchParams({
|
|
18
|
+
response_type: "code",
|
|
19
|
+
client_id: "mcp-client",
|
|
20
|
+
redirect_uri: "http://127.0.0.1:53682/callback",
|
|
21
|
+
state: "s".repeat(43),
|
|
22
|
+
code_challenge: "c".repeat(43),
|
|
23
|
+
code_challenge_method: "S256",
|
|
24
|
+
}).toString();
|
|
25
|
+
|
|
26
|
+
function continueTo(ref: string): Promise<Response> {
|
|
27
|
+
return GET(new Request(`${SITE}/api/auth/mcp/continue/${ref}`), {
|
|
28
|
+
params: Promise.resolve({ ref }),
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
beforeEach(() => {
|
|
33
|
+
process.env = { ...ORIGINAL_ENV, BETTER_AUTH_SECRET: SECRET };
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
afterAll(() => {
|
|
37
|
+
process.env = ORIGINAL_ENV;
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("answers a valid ref with 303 to /api/auth/mcp/authorize and the same query", async () => {
|
|
41
|
+
const res = await continueTo(mintMcpContinuation(QUERY, SECRET));
|
|
42
|
+
|
|
43
|
+
expect(res.status).toBe(303);
|
|
44
|
+
expect(res.headers.get("location")).toBe(`/api/auth/mcp/authorize?${QUERY}`);
|
|
45
|
+
expect(res.headers.get("cache-control")).toBe("no-store");
|
|
46
|
+
expect(res.headers.get("referrer-policy")).toBe("no-referrer");
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it.each([
|
|
50
|
+
["a forged ref", "not-a-ref"],
|
|
51
|
+
["a hub ref", mintContinuation(QUERY, SECRET)],
|
|
52
|
+
["an expired ref", mintMcpContinuation(QUERY, SECRET, Date.now() - 601_000)],
|
|
53
|
+
["a ref sealed under another secret", mintMcpContinuation(QUERY, "x".repeat(43))],
|
|
54
|
+
])("sends %s to /account, never to a URL it carries", async (_name, ref) => {
|
|
55
|
+
const res = await continueTo(ref);
|
|
56
|
+
|
|
57
|
+
expect(res.status).toBe(303);
|
|
58
|
+
expect(res.headers.get("location")).toBe("/account");
|
|
59
|
+
});
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The MCP sign-in leg's continue step (#10479).
|
|
3
|
+
*
|
|
4
|
+
* GET /api/auth/mcp/continue/<ref>
|
|
5
|
+
*
|
|
6
|
+
* After sign-in — by the emailed link, the password or a social provider —
|
|
7
|
+
* the person lands here, and this route returns them to the authorize step:
|
|
8
|
+
*
|
|
9
|
+
* - a valid, unexpired ref: 303 to `/api/auth/mcp/authorize?<the query>`;
|
|
10
|
+
* - any other ref (forged, a hub ref, expired, unreadable): 303 to
|
|
11
|
+
* `/account`.
|
|
12
|
+
*
|
|
13
|
+
* Both targets are fixed relative paths: this route redirects nowhere else,
|
|
14
|
+
* and never to a URL carried in the ref. It reads no session — the authorize
|
|
15
|
+
* step does, and forces consent. It logs nothing.
|
|
16
|
+
*/
|
|
17
|
+
import { resolveSecret } from "../../../../../../lib/auth-secret";
|
|
18
|
+
import { openMcpContinuation } from "../../continuation";
|
|
19
|
+
|
|
20
|
+
const AUTHORIZE_PATH = "/api/auth/mcp/authorize";
|
|
21
|
+
|
|
22
|
+
/** Where an unreadable ref goes: the site's own signed-in destination. */
|
|
23
|
+
const FALLBACK_PATH = "/account";
|
|
24
|
+
|
|
25
|
+
// platform-decisions-waiver: api-versioning — sign-in redirect step, not a versioned API contract (#10479)
|
|
26
|
+
export async function GET(
|
|
27
|
+
_request: Request,
|
|
28
|
+
{ params }: { params: Promise<{ ref: string }> },
|
|
29
|
+
): Promise<Response> {
|
|
30
|
+
const { ref } = await params;
|
|
31
|
+
const query = openMcpContinuation(ref, resolveSecret());
|
|
32
|
+
return new Response(null, {
|
|
33
|
+
status: 303,
|
|
34
|
+
headers: {
|
|
35
|
+
Location: query === null ? FALLBACK_PATH : `${AUTHORIZE_PATH}?${query}`,
|
|
36
|
+
"Cache-Control": "no-store",
|
|
37
|
+
// The authorize query rides this URL; no page may leak it through a Referer.
|
|
38
|
+
"Referrer-Policy": "no-referrer",
|
|
39
|
+
},
|
|
40
|
+
});
|
|
41
|
+
}
|