Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
96 changes: 95 additions & 1 deletion apps/auth-worker/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,17 @@
import { Hono } from "hono";
import { cors } from "hono/cors";
import type { DeviceAuthorizationResponse, TokenResponse } from "./types.ts";
import type { DeviceAuthorizationResponse, MagicAuthResponse, TokenResponse } from "./types.ts";

type Bindings = {
WORKOS_API_KEY: string;
WORKOS_CLIENT_ID: string;
MAGIC_AUTH_LIMITER: RateLimit;
};

interface RateLimit {
limit: (options: { key: string }) => Promise<{ success: boolean }>;
}

const app = new Hono<{ Bindings: Bindings }>();

app.use(
Expand Down Expand Up @@ -100,6 +105,95 @@ app.post("/auth/device/token", async (c) => {
});
});

// Magic Auth - send verification code via email
app.post("/auth/magic", async (c) => {
const body = await c.req.json<{ email: string }>();

if (!body.email) {
return c.json({ error: "missing_email", message: "Email is required" }, 400);
}

// Rate limit by IP
const ip = c.req.header("cf-connecting-ip") || "unknown";
const { success: withinLimit } = await c.env.MAGIC_AUTH_LIMITER.limit({ key: ip });
if (!withinLimit) {
return c.json({ error: "rate_limited", message: "Too many requests. Try again later." }, 429);
}

const workosResponse = await fetch("https://api.workos.com/user_management/magic_auth", {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${c.env.WORKOS_API_KEY}`,
},
body: JSON.stringify({ email: body.email }),
});

if (!workosResponse.ok) {
const error = await workosResponse.json();
return c.json({ error: "workos_error", details: error }, 500);
}

const data = (await workosResponse.json()) as MagicAuthResponse;

// Return ONLY the id and email — NOT the code
return c.json({
id: data.id,
email: data.email,
});
});

// Magic Auth - verify code and exchange for tokens
app.post("/auth/magic/verify", async (c) => {
const body = await c.req.json<{ email: string; code: string }>();

if (!body.email || !body.code) {
return c.json({ error: "missing_fields", message: "Email and code are required" }, 400);
}

const workosResponse = await fetch("https://api.workos.com/user_management/authenticate", {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${c.env.WORKOS_API_KEY}`,
},
body: JSON.stringify({
client_id: c.env.WORKOS_CLIENT_ID,
client_secret: c.env.WORKOS_API_KEY,
grant_type: "urn:workos:oauth:grant-type:magic-auth:code",
code: body.code,
email: body.email,
}),
});

const data = await workosResponse.json();

if (data.error === "invalid_grant") {
return c.json({ error: "invalid_code", message: "Invalid or expired code" }, 400);
}

if (data.error === "expired_token" || data.error === "code_expired") {
return c.json({ error: "expired", message: "Code expired. Please request a new one." }, 410);
}

if (data.error) {
return c.json({ error: data.error, message: data.error_description }, 400);
}

const tokenData = data as TokenResponse;
return c.json({
access_token: tokenData.access_token,
refresh_token: tokenData.refresh_token,
expires_in: tokenData.expires_in,
user: {
id: tokenData.user.id,
email: tokenData.user.email,
first_name: tokenData.user.first_name,
last_name: tokenData.user.last_name,
},
});
});

// Token Refresh
app.post("/auth/refresh", async (c) => {
const body = await c.req.json<{ refresh_token: string }>();
Expand Down
10 changes: 10 additions & 0 deletions apps/auth-worker/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,16 @@ export interface AuthorizationPendingResponse {
error_description: string;
}

export interface MagicAuthResponse {
id: string;
user_id: string;
email: string;
expires_at: string;
code: string; // present in WorkOS response but we NEVER return this
created_at: string;
updated_at: string;
}

export interface WorkOSErrorResponse {
error: string;
error_description: string;
Expand Down
6 changes: 6 additions & 0 deletions apps/auth-worker/wrangler.toml
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,9 @@ WORKOS_CLIENT_ID = "client_01KD3D0QAQF4YXP4ZS8DX00CW6"

# Secrets (set via `wrangler secret put`):
# - WORKOS_API_KEY

[[unsafe.bindings]]
name = "MAGIC_AUTH_LIMITER"
type = "ratelimit"
namespace_id = "1001"
simple = { limit = 5, period = 60 }
39 changes: 39 additions & 0 deletions apps/cli/src/commands/login.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,50 @@
import { type LoginFlowOptions, runLoginFlow } from "../lib/auth/login-flow.ts";
import { error, info } from "../lib/output.ts";

interface LoginOptions {
/** Skip the initial "Logging in..." message (used when called from auto-login) */
silent?: boolean;
/** Email address for magic auth flow (headless login) */
email?: string;
/** 6-digit verification code (skip send step, verify directly) */
code?: string;
}

export default async function login(options: LoginOptions = {}): Promise<void> {
const email = options.email;

// --code without --email is a mistake
if (options.code && !email) {
error("--code requires --email");
info("Usage: jack login --email you@example.com --code 123456");
process.exit(1);
}

if (email) {
const { runMagicAuthFlow } = await import("../lib/auth/login-flow.ts");
const result = await runMagicAuthFlow({
email,
code: options.code,
silent: options.silent,
});
if (!result.success) process.exit(1);

// Print token to stdout so agents can capture it programmatically
if (result.token && (!process.stdout.isTTY || process.env.CI)) {
process.stdout.write(`${result.token}\n`);
}
return;
}

// TTY guardrail: fail fast if no browser possible (CI counts as non-interactive)
if (!process.stdout.isTTY || process.env.CI) {
error("Cannot open browser in this environment.");
info("Use: jack login --email you@example.com");
info("Or set JACK_API_TOKEN for headless use.");
process.exit(1);
}

// Existing device flow
const flowOptions: LoginFlowOptions = {
silent: options.silent,
};
Expand Down
14 changes: 13 additions & 1 deletion apps/cli/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,12 @@ const cli = meow(
to: {
type: "string",
},
email: {
type: "string",
},
code: {
type: "string",
},
},
},
);
Expand Down Expand Up @@ -485,7 +491,13 @@ try {
}
case "login": {
const { default: login } = await import("./commands/login.ts");
await withTelemetry("login", login)();
await withTelemetry(
"login",
login,
)({
email: cli.flags.email || process.env.JACK_EMAIL,
code: cli.flags.code,
});
break;
}
case "logout": {
Expand Down
35 changes: 35 additions & 0 deletions apps/cli/src/lib/auth/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,41 @@ export async function pollDeviceToken(deviceCode: string): Promise<TokenResponse
return response.json() as Promise<TokenResponse>;
}

export interface MagicAuthStartResponse {
id: string;
email: string;
}

export async function startMagicAuth(email: string): Promise<MagicAuthStartResponse> {
const response = await fetch(`${getAuthApiUrl()}/auth/magic`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email }),
});

if (!response.ok) {
const errorBody = (await response.json().catch(() => ({}))) as { message?: string };
throw new Error(errorBody.message || "Failed to send verification code");
}

return response.json() as Promise<MagicAuthStartResponse>;
}

export async function verifyMagicAuth(email: string, code: string): Promise<TokenResponse> {
const response = await fetch(`${getAuthApiUrl()}/auth/magic/verify`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email, code }),
});

if (!response.ok) {
const errorBody = (await response.json().catch(() => ({}))) as { message?: string };
throw new Error(errorBody.message || "Invalid or expired code");
}

return response.json() as Promise<TokenResponse>;
}

export async function refreshToken(refreshTokenValue: string): Promise<TokenResponse> {
const response = await fetch(`${getAuthApiUrl()}/auth/refresh`, {
method: "POST",
Expand Down
2 changes: 1 addition & 1 deletion apps/cli/src/lib/auth/ensure-auth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -242,7 +242,7 @@ describe("ensureAuthForCreate", () => {
mockHasWrangler.mockResolvedValue(false);

await expect(ensureAuthForCreate({ interactive: false })).rejects.toThrow(
"Not logged in and wrangler not authenticated",
"Not logged in. Run 'jack login --email <email>' or set JACK_API_TOKEN for headless use.",
);
});

Expand Down
2 changes: 1 addition & 1 deletion apps/cli/src/lib/auth/ensure-auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -143,7 +143,7 @@ export async function ensureAuthForCreate(
if (!interactive) {
// Non-interactive and no auth available - this is an error condition
throw new Error(
"Not logged in and wrangler not authenticated. Run 'jack login' or 'wrangler login' first.",
"Not logged in. Run 'jack login --email <email>' or set JACK_API_TOKEN for headless use.",
);
}

Expand Down
2 changes: 1 addition & 1 deletion apps/cli/src/lib/auth/guard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ export async function requireAuth(): Promise<string> {
throw new JackError(
JackErrorCode.AUTH_FAILED,
"Not logged in",
"Run 'jack login' to sign in, or set JACK_API_TOKEN for headless use",
"Run 'jack login' to sign in, 'jack login --email <email>' for headless, or set JACK_API_TOKEN",
);
}

Expand Down
6 changes: 6 additions & 0 deletions apps/cli/src/lib/auth/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@ export {
pollDeviceToken,
refreshToken,
startDeviceAuth,
startMagicAuth,
verifyMagicAuth,
type MagicAuthStartResponse,
} from "./client.ts";
export {
ensureAuthForCreate,
Expand All @@ -14,8 +17,11 @@ export {
export { requireAuth, requireAuthOrLogin, getCurrentUser } from "./guard.ts";
export {
runLoginFlow,
runMagicAuthFlow,
type LoginFlowOptions,
type LoginFlowResult,
type MagicAuthFlowOptions,
type MagicAuthFlowResult,
} from "./login-flow.ts";
export {
deleteCredentials,
Expand Down
Loading