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
5 changes: 5 additions & 0 deletions .changeset/login-local-delivery.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"seamless-cli": minor
---

Add `seamless login --local` for self-hosted and local instances. It asks the instance to return the email or phone OTP in the response body instead of sending it, then verifies with that code automatically, so logins work without a real mail or SMS provider. It only runs against local hosts and requires the auth API to run outside production with `ALLOW_UNCREDENTIALED_DELIVERY_SECRETS=true`.
8 changes: 4 additions & 4 deletions resources/coverage-badge.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
8 changes: 7 additions & 1 deletion src/commands/help.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ USAGE
seamless bootstrap-admin [email] [--profile <name>]
seamless verify [--api-only] [--filter=<flow>] [--keep-up]
seamless profile <list|add|use|remove>
seamless login [identifier] [--identifier <email>] [--profile <name>]
seamless login [identifier] [--identifier <email>] [--local] [--profile <name>]
seamless whoami [--profile <name>]
seamless logout [--all] [--profile <name>]
seamless sessions [list]
Expand Down Expand Up @@ -70,6 +70,12 @@ COMMANDS
Prompts for the identifier (or pass it positionally or with --identifier)
and the code sent to your inbox, then stores the session in the OS keychain.

--local
• For local instances only. Asks the instance to return the OTP in the
response instead of emailing it, and verifies with it automatically.
• Requires the auth API to run outside production with
ALLOW_UNCREDENTIALED_DELIVERY_SECRETS=true.

--profile <name>
• Log in against a specific profile instead of the active one
• Also selectable with the SEAMLESS_PROFILE environment variable
Expand Down
45 changes: 45 additions & 0 deletions src/commands/login.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -365,6 +365,51 @@ describe("runLogin: success", () => {
});
});

describe("runLogin: local delivery", () => {
beforeEach(() => {
upsertProfile({ name: "default", instanceUrl: "http://localhost:5312" });
});

it("auto-fills the OTP from the response without prompting for a code", async () => {
const calls = mockRouter({
"/login": [
() => json({ token: "e1", identifierType: "email", loginMethods: ["email_otp"] }),
],
"/otp/generate-login-email-otp": [
() => json({ message: "sent", delivery: { kind: "otp_email", token: "ABCDEF" } }),
],
"/otp/verify-login-email-otp": [
() => json({ token: "a", refreshToken: "r", sub: "user-1", email: "dev@example.com" }),
],
});
vi.mocked(text).mockResolvedValueOnce("dev@example.com");

await runLogin(["--local"]);

// Only the identifier prompt runs; the code is never prompted.
expect(vi.mocked(text)).toHaveBeenCalledTimes(1);
const stored = await getTokens({ name: "default", instanceUrl: "http://localhost:5312" });
expect(stored?.accessToken).toBe("a");

const generate = calls.find((c) =>
c.url.endsWith("/otp/generate-login-email-otp"),
)!;
expect(
(generate.init.headers as Record<string, string>)["x-seamless-auth-delivery-mode"],
).toBe("external");
});

it("rejects --local against a non-local instance", async () => {
upsertProfile({ name: "default", instanceUrl: "https://auth.example.com" });

await expect(runLogin(["--local"])).rejects.toThrow("exit:1");
expect(errorSpy).toHaveBeenCalledWith(
expect.stringContaining("--local only works against a local instance"),
);
expect(exitSpy).toHaveBeenCalledWith(1);
});
});

describe("runLogin: failure handling", () => {
beforeEach(() => {
upsertProfile(profile);
Expand Down
29 changes: 27 additions & 2 deletions src/commands/login.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,21 @@
import { intro, outro, text, isCancel, cancel } from "@clack/prompts";
import kleur from "kleur";
import { extractFlag } from "../core/args.js";
import { getActiveProfile, upsertProfile, type Profile } from "../core/config.js";
import {
getActiveProfile,
isLocalInstanceUrl,
upsertProfile,
type Profile,
} from "../core/config.js";
import { KeychainUnavailableError, saveTokens } from "../core/keychain.js";
import { completeLogin, LoginError, type LoginResult } from "../core/loginFlow.js";

export async function runLogin(args: string[]): Promise<void> {
const profileFlag = extractFlag(args, "profile");
const local = args.includes("--local");
const profileFlag = extractFlag(
args.filter((a) => a !== "--local"),
"profile",
);
const idFlag = extractFlag(profileFlag.rest, "identifier");

const profile = getActiveProfile({ profileFlag: profileFlag.value });
Expand All @@ -19,7 +28,19 @@ export async function runLogin(args: string[]): Promise<void> {
process.exit(1);
}

if (local && !isLocalInstanceUrl(profile.instanceUrl)) {
console.error(
kleur.red(`--local only works against a local instance, not ${profile.instanceUrl}.`),
);
process.exit(1);
}

intro(`Log in to ${kleur.bold(profile.name)} (${profile.instanceUrl})`);
if (local) {
console.log(
kleur.dim("Local delivery on: reading the OTP from the instance response."),
);
}

let identifier = (idFlag.value ?? idFlag.rest[0])?.trim();
if (!identifier) {
Expand All @@ -41,6 +62,7 @@ export async function runLogin(args: string[]): Promise<void> {
const result = await completeLogin({
instanceUrl: profile.instanceUrl,
identifier,
localDelivery: local,
getCode: async ({ resent, channel }) => {
const email = channel === "email";
const answer = await text({
Expand Down Expand Up @@ -72,6 +94,9 @@ export async function runLogin(args: string[]): Promise<void> {
kleur.dim("Your previous code expired, so we sent a new one."),
);
break;
case "code_autofilled":
console.log(kleur.dim("Read the code from the instance response."));
break;
case "incorrect":
console.log(
kleur.yellow(
Expand Down
14 changes: 12 additions & 2 deletions src/core/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,17 @@ export function getActiveProfile(

const LOCAL_HOSTS = new Set(["localhost", "127.0.0.1", "::1", "[::1]"]);

export function isLocalInstanceUrl(input: string): boolean {
let url: URL;
try {
url = new URL((input ?? "").trim());
} catch {
return false;
}
const host = url.hostname;
return LOCAL_HOSTS.has(host) || host.endsWith(".localhost");
}

export function normalizeInstanceUrl(input: string): string {
const trimmed = (input ?? "").trim();
if (!trimmed) {
Expand All @@ -193,8 +204,7 @@ export function normalizeInstanceUrl(input: string): string {
}

const host = url.hostname;
const isLocal = LOCAL_HOSTS.has(host) || host.endsWith(".localhost");
if (url.protocol === "http:" && !isLocal) {
if (url.protocol === "http:" && !isLocalInstanceUrl(trimmed)) {
throw new Error(
`Instance URL must use https for non-local host "${host}".`,
);
Expand Down
77 changes: 77 additions & 0 deletions src/core/loginFlow.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -443,6 +443,83 @@ describe("completeLogin", () => {
).rejects.toThrow(/unexpected verification response/);
});

it("auto-fills the code from external delivery in local mode", async () => {
const calls = mockRouter({
"/login": [
() => json({ token: "e1", identifierType: "email", loginMethods: ["email_otp"] }),
],
"/otp/generate-login-email-otp": [
() => json({ message: "sent", delivery: { kind: "otp_email", token: "ABCDEF" } }),
],
"/otp/verify-login-email-otp": [() => json({ token: "a", refreshToken: "r" })],
});

let asked = 0;
const result = await completeLogin({
instanceUrl: INSTANCE,
identifier: "dev@example.com",
localDelivery: true,
getCode: async () => {
asked++;
return "999999";
},
});

expect(asked).toBe(0);
expect(result?.tokens.accessToken).toBe("a");

const generate = calls.find((c) =>
c.url.endsWith("/otp/generate-login-email-otp"),
)!;
expect(
(generate.init.headers as Record<string, string>)["x-seamless-auth-delivery-mode"],
).toBe("external");

const verify = calls.find((c) => c.url.endsWith("/otp/verify-login-email-otp"))!;
expect(verify.init.body).toBe(JSON.stringify({ verificationToken: "ABCDEF" }));
});

it("errors in local mode when the instance does not return the code", async () => {
mockRouter({
"/login": [
() => json({ token: "e1", identifierType: "email", loginMethods: ["email_otp"] }),
],
"/otp/generate-login-email-otp": [() => json({ message: "sent" })],
});

await expect(
completeLogin({
instanceUrl: INSTANCE,
identifier: "dev@example.com",
localDelivery: true,
getCode: async () => "123456",
}),
).rejects.toThrow(/ALLOW_UNCREDENTIALED_DELIVERY_SECRETS/);
});

it("does not request external delivery when local mode is off", async () => {
const calls = mockRouter({
"/login": [
() => json({ token: "e1", identifierType: "email", loginMethods: ["email_otp"] }),
],
"/otp/generate-login-email-otp": [() => json({ message: "sent" })],
"/otp/verify-login-email-otp": [() => json({ token: "a", refreshToken: "r" })],
});

await completeLogin({
instanceUrl: INSTANCE,
identifier: "dev@example.com",
getCode: async () => "123456",
});

const generate = calls.find((c) =>
c.url.endsWith("/otp/generate-login-email-otp"),
)!;
expect(
(generate.init.headers as Record<string, string>)["x-seamless-auth-delivery-mode"],
).toBeUndefined();
});

it("surfaces the rate limiter when verifying a code", async () => {
mockRouter({
"/login": [
Expand Down
Loading
Loading