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
144 changes: 144 additions & 0 deletions packages/cli/src/__tests__/login-email.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
import { afterEach, describe, expect, mock, spyOn, test } from "bun:test";
import { loginCommand } from "../commands/login";
import * as context from "../internal/context";
import * as credentials from "../internal/credentials";

/**
* Regression guard for `lobu login --email`: after the server accepts the
* email claim, the command MUST keep polling and save the credential. An
* earlier version checked the void result of the claim call as falsy and
* returned immediately, so the email sent but no token was ever collected.
*/
describe("login --email (user_claimed)", () => {
afterEach(() => {
mock.restore();
});

function mockOAuthServer(): { calls: string[] } {
const calls: string[] = [];
const fetchMock = mock(async (input: RequestInfo | URL) => {
const url = typeof input === "string" ? input : (input as Request).url;
calls.push(url);
if (url.endsWith("/.well-known/oauth-authorization-server")) {
return jsonResponse({
issuer: "https://lobu.test",
token_endpoint: "https://lobu.test/oauth/token",
registration_endpoint: "https://lobu.test/oauth/register",
device_authorization_endpoint:
"https://lobu.test/oauth/device_authorization",
grant_types_supported: ["urn:ietf:params:oauth:grant-type:device_code"],
agent_auth: { claim_email_endpoint: "https://lobu.test/oauth/device/email" },
});
}
if (url.endsWith("/oauth/register")) {
return jsonResponse({ client_id: "agent-client" });
}
if (url.endsWith("/oauth/device_authorization")) {
return jsonResponse({
device_code: "dev-code",
user_code: "ABCD-1234",
verification_uri: "https://lobu.test/oauth/device",
expires_in: 600,
interval: 0,
});
}
if (url.endsWith("/oauth/device/email")) {
return jsonResponse({ status: "pending" }, 202);
}
if (url.endsWith("/oauth/token")) {
return jsonResponse({
access_token: "claimed-access-token",
refresh_token: "claimed-refresh-token",
expires_in: 3600,
});
}
throw new Error(`unexpected fetch: ${url}`);
});
globalThis.fetch = fetchMock as unknown as typeof fetch;
return { calls };
}

test("sends the email claim, polls, and saves the collected credential", async () => {
spyOn(context, "resolveContext").mockResolvedValue({
name: "prod",
url: "https://lobu.test/lobu/api/v1",
source: "config",
});
spyOn(credentials, "loadCredentials").mockResolvedValue(null);
const saveSpy = spyOn(credentials, "saveCredentials").mockResolvedValue();

const originalFetch = globalThis.fetch;
const { calls } = mockOAuthServer();
try {
await loginCommand({ email: "user@example.com", context: "prod" });
} finally {
globalThis.fetch = originalFetch;
}

// It must have hit the claim endpoint AND gone on to poll the token endpoint.
expect(calls.some((u) => u.endsWith("/oauth/device/email"))).toBe(true);
expect(calls.some((u) => u.endsWith("/oauth/token"))).toBe(true);
// ...and persisted the collected access token (the bug skipped this).
expect(saveSpy).toHaveBeenCalledTimes(1);
expect(saveSpy.mock.calls[0]?.[0]).toMatchObject({
accessToken: "claimed-access-token",
});
});

test("errors without polling when the server has no claim endpoint", async () => {
spyOn(context, "resolveContext").mockResolvedValue({
name: "prod",
url: "https://lobu.test/lobu/api/v1",
source: "config",
});
spyOn(credentials, "loadCredentials").mockResolvedValue(null);
const saveSpy = spyOn(credentials, "saveCredentials").mockResolvedValue();

const calls: string[] = [];
const fetchMock = mock(async (input: RequestInfo | URL) => {
const url = typeof input === "string" ? input : (input as Request).url;
calls.push(url);
if (url.endsWith("/.well-known/oauth-authorization-server")) {
return jsonResponse({
token_endpoint: "https://lobu.test/oauth/token",
registration_endpoint: "https://lobu.test/oauth/register",
device_authorization_endpoint:
"https://lobu.test/oauth/device_authorization",
grant_types_supported: ["urn:ietf:params:oauth:grant-type:device_code"],
// no agent_auth → email claim unsupported
});
}
if (url.endsWith("/oauth/register")) return jsonResponse({ client_id: "c" });
if (url.endsWith("/oauth/device_authorization")) {
return jsonResponse({
device_code: "d",
user_code: "E-F",
verification_uri: "https://lobu.test/oauth/device",
expires_in: 600,
interval: 0,
});
}
throw new Error(`unexpected fetch: ${url}`);
});
const originalFetch = globalThis.fetch;
globalThis.fetch = fetchMock as unknown as typeof fetch;
try {
await loginCommand({ email: "user@example.com", context: "prod" });
} finally {
globalThis.fetch = originalFetch;
}

expect(calls.some((u) => u.endsWith("/oauth/device/email"))).toBe(false);
expect(calls.some((u) => u.endsWith("/oauth/token"))).toBe(false);
expect(saveSpy).not.toHaveBeenCalled();
expect(process.exitCode).toBe(1);
process.exitCode = 0;
});
});

function jsonResponse(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), {
status,
headers: { "Content-Type": "application/json" },
});
}
150 changes: 119 additions & 31 deletions packages/cli/src/commands/login.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,13 @@ interface LoginOptions {
cliVersion?: string;
/** Suppress spinner output; bail out non-interactively if the server rejects polling. */
quiet?: boolean;
/**
* Headless email "user_claimed" login (auth.md): the server emails this
* address a one-click approval link instead of showing a code, and we keep
* polling without a TTY. Lets an agent log in on a user's behalf without a
* pre-minted PAT. Requires the server to advertise `agent_auth.claim_email_endpoint`.
*/
email?: string;
}

/**
Expand Down Expand Up @@ -109,6 +116,19 @@ export async function loginCommand(options: LoginOptions): Promise<void> {
return;
}

// For --email, fail before creating an OAuth client / device code on a server
// that can't deliver the email claim anyway.
if (options.email && !discovery.claimEmailEndpoint) {
console.log(
chalk.red(
`\n ${discovery.issuer} does not support email login (no agent_auth.claim_email_endpoint).`
)
);
console.log(chalk.dim(" Use plain `lobu login` or `--token <pat>`.\n"));
process.exitCode = 1;
return;
}

console.log(chalk.dim(`\n Context: ${target.name}`));
console.log(chalk.dim(` Issuer: ${discovery.issuer}`));

Expand All @@ -122,42 +142,73 @@ export async function loginCommand(options: LoginOptions): Promise<void> {
);
if (!authorization) return;

const verificationUrl =
authorization.verificationUriComplete ?? authorization.verificationUri;

console.log(chalk.dim("\n Open this URL to approve the login:"));
console.log(chalk.cyan(` ${verificationUrl}`));
console.log(chalk.dim(` Code: ${chalk.bold.white(authorization.userCode)}`));
if (
authorization.verificationUriComplete &&
authorization.verificationUriComplete !== authorization.verificationUri
) {
console.log(chalk.dim(` Or visit: ${authorization.verificationUri}\n`));
// Headless email "user_claimed" login (auth.md): instead of showing a code
// for a human at this terminal, ask the server to email an approval link to
// `--email`. Approval happens out of band, so we then poll regardless of TTY.
const emailClaim = Boolean(options.email);
if (emailClaim) {
// Support was already verified above, before client/device-code creation.
// tryOAuthStep returns the callback's value or undefined on error;
// sendEmailClaim resolves void, so return a truthy sentinel to distinguish
// success from the error case (otherwise we'd bail before polling).
const sent = await tryOAuthStep(async () => {
await sendEmailClaim(
discovery.claimEmailEndpoint as string,
authorization.userCode,
options.email as string
);
return true;
});
if (!sent) return;
console.log(
chalk.dim(
`\n Sent a confirmation link to ${chalk.white(options.email as string)}.`
)
);
console.log(
chalk.dim(" Waiting for the user to approve from their email...\n")
);
} else {
console.log();
}
const verificationUrl =
authorization.verificationUriComplete ?? authorization.verificationUri;

// Refuse to hand a non-https URL (e.g. javascript:, data:, file:) to the
// OS's `open` handler. A compromised/misconfigured discovery endpoint
// could otherwise redirect the user's browser into running attacker code.
let canOpen = false;
try {
canOpen = new URL(verificationUrl).protocol === "https:";
} catch {
canOpen = false;
}
if (canOpen) {
console.log(chalk.dim("\n Open this URL to approve the login:"));
console.log(chalk.cyan(` ${verificationUrl}`));
console.log(
chalk.dim(` Code: ${chalk.bold.white(authorization.userCode)}`)
);
if (
authorization.verificationUriComplete &&
authorization.verificationUriComplete !== authorization.verificationUri
) {
console.log(chalk.dim(` Or visit: ${authorization.verificationUri}\n`));
} else {
console.log();
}

// Refuse to hand a non-https URL (e.g. javascript:, data:, file:) to the
// OS's `open` handler. A compromised/misconfigured discovery endpoint
// could otherwise redirect the user's browser into running attacker code.
let canOpen = false;
try {
await open(verificationUrl);
canOpen = new URL(verificationUrl).protocol === "https:";
} catch {
// The URL is printed above; opening is best-effort.
canOpen = false;
}
if (canOpen) {
try {
await open(verificationUrl);
} catch {
// The URL is printed above; opening is best-effort.
}
}
}

// Both ends of the stdio pair must be a TTY for the device-code prompt to
// make sense — a backgrounded shell or CI runner has neither stdin to
// approve from nor stdout to spin on. Require both, plus the absence of
// `--quiet`, before treating the call as interactive.
// `--quiet`, before treating the call as interactive. Email-claim approval
// is out of band, so it polls even without a TTY.
const isInteractive =
process.stdout.isTTY === true &&
process.stdin.isTTY === true &&
Expand Down Expand Up @@ -225,15 +276,18 @@ export async function loginCommand(options: LoginOptions): Promise<void> {
);

if (result.status === "pending") {
// Non-interactive callers (CI, backgrounded shells) can't approve
// the device code, so a `pending` poll is the terminal answer —
// bail out instead of looping until expiry.
if (!isInteractive) {
// Non-interactive callers (CI, backgrounded shells) can't approve a
// terminal device code, so a `pending` poll is the terminal answer —
// bail instead of looping until expiry. Email-claim is the exception:
// approval rides an emailed link, so we keep polling without a TTY.
if (!isInteractive && !emailClaim) {
console.log(
chalk.red(" Device-code login requires an interactive terminal.")
);
console.log(
chalk.dim(" Use `--token <pat>` for non-interactive auth.\n")
chalk.dim(
" Use `--token <pat>`, or `--email <addr>` for headless approval.\n"
)
);
process.exitCode = 1;
return;
Expand Down Expand Up @@ -299,6 +353,40 @@ export async function loginCommand(options: LoginOptions): Promise<void> {
}
}

/**
* POST the device `user_code` + target email to the auth.md claim endpoint so
* the server emails the user a one-click approval link. The endpoint is opaque
* (202) about whether the address has an account; only a real 4xx/5xx (bad
* user_code, rate limit) is surfaced.
*/
async function sendEmailClaim(
endpoint: string,
userCode: string,
email: string
): Promise<void> {
const res = await fetch(endpoint, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ user_code: userCode, email }),
});
if (!res.ok) {
let detail = "";
try {
const body = (await res.json()) as {
error_description?: string;
error?: string;
};
detail = body.error_description ?? body.error ?? "";
} catch {
// non-JSON error body — status alone is enough
}
throw new OAuthError(
"email_claim_failed",
`Email login request failed (${res.status})${detail ? `: ${detail}` : ""}.`
);
}
}

async function loginWithToken(
target: { url: string; name: string },
rawToken: string
Expand Down
5 changes: 5 additions & 0 deletions packages/cli/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -389,12 +389,17 @@ Memory:
"-q, --quiet",
"Suppress spinner; bail immediately if non-interactive (CI / backgrounded shells)"
)
.option(
"--email <address>",
"Headless login on a user's behalf: the server emails them an approval link (auth.md user_claimed flow)"
)
.action(
async (options: {
token?: string;
context?: string;
force?: boolean;
quiet?: boolean;
email?: string;
}) => {
const { loginCommand } = await import("./commands/login.js");
await loginCommand({ ...options, cliVersion: version });
Expand Down
28 changes: 28 additions & 0 deletions packages/cli/src/internal/__tests__/oauth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,34 @@ describe("oauth", () => {
expect(meta.issuer).toBe("https://issuer.example.com");
});

test("parses the auth.md claim_email_endpoint from the agent_auth block", async () => {
setFetch(() =>
jsonResponse({
token_endpoint: "https://issuer.example.com/token",
agent_auth: {
flows_supported: ["user_claimed"],
claim_email_endpoint: "https://issuer.example.com/oauth/device/email",
},
})
);

const meta = await discoverOAuth("https://api.example.com/v1");

expect(meta.claimEmailEndpoint).toBe(
"https://issuer.example.com/oauth/device/email"
);
});

test("leaves claimEmailEndpoint undefined when agent_auth is absent", async () => {
setFetch(() =>
jsonResponse({ token_endpoint: "https://issuer.example.com/token" })
);

const meta = await discoverOAuth("https://api.example.com/v1");

expect(meta.claimEmailEndpoint).toBeUndefined();
});

test("falls back to origin when issuer field is absent", async () => {
setFetch(() =>
jsonResponse({
Expand Down
Loading
Loading