Skip to content

Commit de1e056

Browse files
volinskeyclaude
andcommitted
feat: magic link auth tools — request, verify, set-password, auth-settings
- 4 new MCP tools: request_magic_link, verify_magic_link, set_user_password, auth_settings - CLI: run402 auth (magic-link, verify, set-password, settings, providers) - OpenClaw shim: openclaw/scripts/auth.mjs - sync.test.ts: updated SURFACE + module lists for parity Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent d0837cb commit de1e056

9 files changed

Lines changed: 366 additions & 2 deletions

File tree

cli/cli.mjs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ Commands:
3737
image Generate AI images via x402 or MPP micropayments
3838
email Send template-based emails from your project
3939
message Send messages to Run402 developers
40+
auth Manage project user authentication (magic link, passwords, settings)
4041
agent Manage agent identity (contact info)
4142
4243
Run 'run402 <command> --help' for detailed usage of each command.
@@ -159,6 +160,11 @@ switch (cmd) {
159160
await run(sub, rest);
160161
break;
161162
}
163+
case "auth": {
164+
const { run } = await import("./lib/auth.mjs");
165+
await run(sub, rest);
166+
break;
167+
}
162168
default:
163169
console.error(`Unknown command: ${cmd}\n`);
164170
console.log(HELP);

cli/lib/auth.mjs

Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
import { findProject, resolveProjectId, API } from "./config.mjs";
2+
3+
const HELP = `run402 auth — Manage project user authentication
4+
5+
Usage:
6+
run402 auth <subcommand> [args...]
7+
8+
Subcommands:
9+
magic-link --email <addr> --redirect <url> [--project <id>]
10+
Send a passwordless login link to the given email. Auto-creates user on first use.
11+
12+
verify --token <token> [--project <id>]
13+
Exchange a magic link token for access_token + refresh_token.
14+
15+
set-password --token <bearer> --new <password> [--current <password>] [--project <id>]
16+
Change, reset, or set a user's password. Requires the user's access_token.
17+
18+
settings --allow-password-set <true|false> [--project <id>]
19+
Update project auth settings (requires service_key).
20+
21+
providers [--project <id>]
22+
List available auth providers for the project.
23+
24+
Examples:
25+
run402 auth magic-link --email user@example.com --redirect https://myapp.run402.com/cb
26+
run402 auth verify --token abc123def456
27+
run402 auth set-password --token eyJ... --new "new-pass" --current "old-pass"
28+
run402 auth settings --allow-password-set true
29+
run402 auth providers
30+
`;
31+
32+
function parseFlag(args, flag) {
33+
for (let i = 0; i < args.length; i++) {
34+
if (args[i] === flag && args[i + 1]) return args[i + 1];
35+
}
36+
return null;
37+
}
38+
39+
async function magicLink(args) {
40+
const email = parseFlag(args, "--email");
41+
const redirect = parseFlag(args, "--redirect");
42+
const projectId = resolveProjectId(parseFlag(args, "--project"));
43+
const p = findProject(projectId);
44+
45+
if (!email) { console.error(JSON.stringify({ status: "error", message: "Missing --email" })); process.exit(1); }
46+
if (!redirect) { console.error(JSON.stringify({ status: "error", message: "Missing --redirect <url>" })); process.exit(1); }
47+
48+
const res = await fetch(`${API}/auth/v1/magic-link`, {
49+
method: "POST",
50+
headers: { "Authorization": `Bearer ${p.anon_key}`, "Content-Type": "application/json" },
51+
body: JSON.stringify({ email, redirect_url: redirect }),
52+
});
53+
const data = await res.json();
54+
if (!res.ok) {
55+
console.error(JSON.stringify({ status: "error", http: res.status, ...data }));
56+
process.exit(1);
57+
}
58+
console.log(JSON.stringify({ status: "ok", ...data }));
59+
}
60+
61+
async function verify(args) {
62+
const token = parseFlag(args, "--token");
63+
const projectId = resolveProjectId(parseFlag(args, "--project"));
64+
const p = findProject(projectId);
65+
66+
if (!token) { console.error(JSON.stringify({ status: "error", message: "Missing --token" })); process.exit(1); }
67+
68+
const res = await fetch(`${API}/auth/v1/token?grant_type=magic_link`, {
69+
method: "POST",
70+
headers: { "Authorization": `Bearer ${p.anon_key}`, "Content-Type": "application/json" },
71+
body: JSON.stringify({ token }),
72+
});
73+
const data = await res.json();
74+
if (!res.ok) {
75+
console.error(JSON.stringify({ status: "error", http: res.status, ...data }));
76+
process.exit(1);
77+
}
78+
console.log(JSON.stringify({ status: "ok", ...data }));
79+
}
80+
81+
async function setPassword(args) {
82+
const accessToken = parseFlag(args, "--token");
83+
const newPassword = parseFlag(args, "--new");
84+
const currentPassword = parseFlag(args, "--current");
85+
86+
if (!accessToken) { console.error(JSON.stringify({ status: "error", message: "Missing --token <bearer_token>" })); process.exit(1); }
87+
if (!newPassword) { console.error(JSON.stringify({ status: "error", message: "Missing --new <password>" })); process.exit(1); }
88+
89+
const body = { new_password: newPassword };
90+
if (currentPassword) body.current_password = currentPassword;
91+
92+
const res = await fetch(`${API}/auth/v1/user/password`, {
93+
method: "PUT",
94+
headers: { "Authorization": `Bearer ${accessToken}`, "Content-Type": "application/json" },
95+
body: JSON.stringify(body),
96+
});
97+
const data = await res.json();
98+
if (!res.ok) {
99+
console.error(JSON.stringify({ status: "error", http: res.status, ...data }));
100+
process.exit(1);
101+
}
102+
console.log(JSON.stringify({ status: "ok", ...data }));
103+
}
104+
105+
async function settings(args) {
106+
const allowPasswordSet = parseFlag(args, "--allow-password-set");
107+
const projectId = resolveProjectId(parseFlag(args, "--project"));
108+
const p = findProject(projectId);
109+
110+
if (allowPasswordSet === null) { console.error(JSON.stringify({ status: "error", message: "Missing --allow-password-set <true|false>" })); process.exit(1); }
111+
112+
const res = await fetch(`${API}/auth/v1/settings`, {
113+
method: "PATCH",
114+
headers: { "Authorization": `Bearer ${p.service_key}`, "Content-Type": "application/json" },
115+
body: JSON.stringify({ allow_password_set: allowPasswordSet === "true" }),
116+
});
117+
const data = await res.json();
118+
if (!res.ok) {
119+
console.error(JSON.stringify({ status: "error", http: res.status, ...data }));
120+
process.exit(1);
121+
}
122+
console.log(JSON.stringify({ status: "ok", ...data }));
123+
}
124+
125+
async function providers(args) {
126+
const projectId = resolveProjectId(parseFlag(args, "--project"));
127+
const p = findProject(projectId);
128+
129+
const res = await fetch(`${API}/auth/v1/providers`, {
130+
headers: { "Authorization": `Bearer ${p.anon_key}` },
131+
});
132+
const data = await res.json();
133+
if (!res.ok) {
134+
console.error(JSON.stringify({ status: "error", http: res.status, ...data }));
135+
process.exit(1);
136+
}
137+
console.log(JSON.stringify(data, null, 2));
138+
}
139+
140+
export async function run(sub, args) {
141+
if (!sub || sub === "--help" || sub === "-h") { console.log(HELP); process.exit(0); }
142+
switch (sub) {
143+
case "magic-link": await magicLink(args); break;
144+
case "verify": await verify(args); break;
145+
case "set-password": await setPassword(args); break;
146+
case "settings": await settings(args); break;
147+
case "providers": await providers(args); break;
148+
default:
149+
console.error(`Unknown subcommand: ${sub}\n`);
150+
console.log(HELP);
151+
process.exit(1);
152+
}
153+
}

openclaw/scripts/auth.mjs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export { run } from "../../cli/lib/auth.mjs";

src/index.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,12 @@ import { listEmailsSchema, handleListEmails } from "./tools/list-emails.js";
7474
import { getEmailSchema, handleGetEmail } from "./tools/get-email.js";
7575
import { getMailboxSchema, handleGetMailbox } from "./tools/get-mailbox.js";
7676

77+
// New tools — magic link auth
78+
import { requestMagicLinkSchema, handleRequestMagicLink } from "./tools/request-magic-link.js";
79+
import { verifyMagicLinkSchema, handleVerifyMagicLink } from "./tools/verify-magic-link.js";
80+
import { setUserPasswordSchema, handleSetUserPassword } from "./tools/set-user-password.js";
81+
import { authSettingsSchema, handleAuthSettings } from "./tools/auth-settings.js";
82+
7783
// New tools — AI
7884
import { aiTranslateSchema, handleAiTranslate } from "./tools/ai-translate.js";
7985
import { aiModerateSchema, handleAiModerate } from "./tools/ai-moderate.js";
@@ -611,5 +617,35 @@ server.tool(
611617
async (args) => handleProjectKeys(args),
612618
);
613619

620+
// --- Magic link auth ---
621+
622+
server.tool(
623+
"request_magic_link",
624+
"Send a passwordless login email (magic link) to a project user. Auto-creates the user on first verification. Rate limited per email (5/hr) and per project (by tier).",
625+
requestMagicLinkSchema,
626+
async (args) => handleRequestMagicLink(args),
627+
);
628+
629+
server.tool(
630+
"verify_magic_link",
631+
"Exchange a magic link token for access_token + refresh_token. Creates the user if they don't exist. Token is single-use and expires in 15 minutes.",
632+
verifyMagicLinkSchema,
633+
async (args) => handleVerifyMagicLink(args),
634+
);
635+
636+
server.tool(
637+
"set_user_password",
638+
"Change, reset, or set a user's password. Change: provide current_password + new_password. Reset (via magic link login): just new_password. Set (passwordless user): requires allow_password_set=true on project.",
639+
setUserPasswordSchema,
640+
async (args) => handleSetUserPassword(args),
641+
);
642+
643+
server.tool(
644+
"auth_settings",
645+
"Update project auth settings. Currently supports allow_password_set (boolean) to control whether passwordless users can add a password. Requires service_key.",
646+
authSettingsSchema,
647+
async (args) => handleAuthSettings(args),
648+
);
649+
614650
const transport = new StdioServerTransport();
615651
await server.connect(transport);

src/tools/auth-settings.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import { z } from "zod";
2+
import { apiRequest } from "../client.js";
3+
import { getProject } from "../keystore.js";
4+
import { formatApiError, projectNotFound } from "../errors.js";
5+
6+
export const authSettingsSchema = {
7+
project_id: z.string().describe("The project ID"),
8+
allow_password_set: z.boolean().describe("Allow passwordless users (magic link / OAuth) to set a password. Default: false."),
9+
};
10+
11+
export async function handleAuthSettings(args: {
12+
project_id: string;
13+
allow_password_set: boolean;
14+
}): Promise<{ content: Array<{ type: "text"; text: string }>; isError?: boolean }> {
15+
const project = getProject(args.project_id);
16+
if (!project) return projectNotFound(args.project_id);
17+
18+
const res = await apiRequest(`/auth/v1/settings`, {
19+
method: "PATCH",
20+
headers: { Authorization: `Bearer ${project.service_key}` },
21+
body: {
22+
allow_password_set: args.allow_password_set,
23+
},
24+
});
25+
26+
if (!res.ok) return formatApiError(res, "updating auth settings");
27+
28+
return {
29+
content: [
30+
{
31+
type: "text",
32+
text: `## Auth Settings Updated\n\n- **allow_password_set:** ${args.allow_password_set}\n\n${args.allow_password_set ? "Passwordless users can now set a password via PUT /auth/v1/user/password." : "Passwordless users cannot set a password. They must use magic link or OAuth to sign in."}`,
33+
},
34+
],
35+
};
36+
}

src/tools/request-magic-link.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import { z } from "zod";
2+
import { apiRequest } from "../client.js";
3+
import { getProject } from "../keystore.js";
4+
import { formatApiError, projectNotFound } from "../errors.js";
5+
6+
export const requestMagicLinkSchema = {
7+
project_id: z.string().describe("The project ID"),
8+
email: z.string().describe("Email address to send the magic link to"),
9+
redirect_url: z.string().describe("URL to redirect to after clicking the magic link. Must be an allowed origin for this project (localhost, claimed subdomain, or custom domain)."),
10+
};
11+
12+
export async function handleRequestMagicLink(args: {
13+
project_id: string;
14+
email: string;
15+
redirect_url: string;
16+
}): Promise<{ content: Array<{ type: "text"; text: string }>; isError?: boolean }> {
17+
const project = getProject(args.project_id);
18+
if (!project) return projectNotFound(args.project_id);
19+
20+
const res = await apiRequest(`/auth/v1/magic-link`, {
21+
method: "POST",
22+
headers: { Authorization: `Bearer ${project.anon_key}` },
23+
body: {
24+
email: args.email,
25+
redirect_url: args.redirect_url,
26+
},
27+
});
28+
29+
if (!res.ok) return formatApiError(res, "requesting magic link");
30+
31+
return {
32+
content: [
33+
{
34+
type: "text",
35+
text: `## Magic Link Sent\n\n- **Email:** ${args.email}\n- **Redirect:** ${args.redirect_url}\n\nThe user will receive an email with a login link. The link expires in 15 minutes. If they don't have an account, one will be created automatically when they verify the link.`,
36+
},
37+
],
38+
};
39+
}

src/tools/set-user-password.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import { z } from "zod";
2+
import { apiRequest } from "../client.js";
3+
import { getProject } from "../keystore.js";
4+
import { formatApiError, projectNotFound } from "../errors.js";
5+
6+
export const setUserPasswordSchema = {
7+
project_id: z.string().describe("The project ID"),
8+
access_token: z.string().describe("The user's access_token (Bearer token from login)"),
9+
new_password: z.string().describe("The new password to set"),
10+
current_password: z.string().optional().describe("Current password (required for password change, omit for reset via magic link or initial set)"),
11+
};
12+
13+
export async function handleSetUserPassword(args: {
14+
project_id: string;
15+
access_token: string;
16+
new_password: string;
17+
current_password?: string;
18+
}): Promise<{ content: Array<{ type: "text"; text: string }>; isError?: boolean }> {
19+
const project = getProject(args.project_id);
20+
if (!project) return projectNotFound(args.project_id);
21+
22+
const body: Record<string, string> = { new_password: args.new_password };
23+
if (args.current_password) body.current_password = args.current_password;
24+
25+
const res = await apiRequest(`/auth/v1/user/password`, {
26+
method: "PUT",
27+
headers: { Authorization: `Bearer ${args.access_token}` },
28+
body,
29+
});
30+
31+
if (!res.ok) return formatApiError(res, "setting user password");
32+
33+
const mode = args.current_password ? "changed" : "set";
34+
return {
35+
content: [
36+
{
37+
type: "text",
38+
text: `## Password ${mode.charAt(0).toUpperCase() + mode.slice(1)}\n\nPassword successfully ${mode} for the authenticated user. They can now log in with email + password.`,
39+
},
40+
],
41+
};
42+
}

src/tools/verify-magic-link.ts

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import { z } from "zod";
2+
import { apiRequest } from "../client.js";
3+
import { getProject } from "../keystore.js";
4+
import { formatApiError, projectNotFound } from "../errors.js";
5+
6+
export const verifyMagicLinkSchema = {
7+
project_id: z.string().describe("The project ID"),
8+
token: z.string().describe("The magic link token from the email link URL (?token=...)"),
9+
};
10+
11+
export async function handleVerifyMagicLink(args: {
12+
project_id: string;
13+
token: string;
14+
}): Promise<{ content: Array<{ type: "text"; text: string }>; isError?: boolean }> {
15+
const project = getProject(args.project_id);
16+
if (!project) return projectNotFound(args.project_id);
17+
18+
const res = await apiRequest(`/auth/v1/token?grant_type=magic_link`, {
19+
method: "POST",
20+
headers: { Authorization: `Bearer ${project.anon_key}` },
21+
body: {
22+
token: args.token,
23+
},
24+
});
25+
26+
if (!res.ok) return formatApiError(res, "verifying magic link");
27+
28+
const body = res.body as {
29+
access_token: string;
30+
refresh_token: string;
31+
token_type: string;
32+
expires_in: number;
33+
user: { id: string; email: string };
34+
};
35+
36+
return {
37+
content: [
38+
{
39+
type: "text",
40+
text: `## Magic Link Verified\n\n- **User ID:** \`${body.user.id}\`\n- **Email:** ${body.user.email}\n- **Access Token:** \`${body.access_token.slice(0, 20)}...\`\n- **Refresh Token:** \`${body.refresh_token.slice(0, 8)}...\`\n- **Expires In:** ${body.expires_in}s\n\nThe user is now authenticated. Use the access_token as a Bearer token for authenticated API calls.`,
41+
},
42+
],
43+
};
44+
}

0 commit comments

Comments
 (0)