From 90436437532ee35fc26461389111391a740819bf Mon Sep 17 00:00:00 2001 From: Brandon Corbett Date: Tue, 21 Jul 2026 16:45:31 -0400 Subject: [PATCH] feat(login): add --local to read the OTP from the instance response Local and self-hosted instances without a real mail or SMS provider could not complete OTP login, since the code was never surfaced. --local asks the instance for external delivery, reads the code from the response, and verifies with it automatically. It is gated to local hosts and requires the auth API to run outside production with ALLOW_UNCREDENTIALED_DELIVERY_SECRETS=true. --- .changeset/login-local-delivery.md | 5 ++ resources/coverage-badge.svg | 8 ++-- src/commands/help.ts | 8 +++- src/commands/login.test.ts | 45 +++++++++++++++++ src/commands/login.ts | 29 ++++++++++- src/core/config.ts | 14 +++++- src/core/loginFlow.test.ts | 77 ++++++++++++++++++++++++++++++ src/core/loginFlow.ts | 55 +++++++++++++++++++-- 8 files changed, 227 insertions(+), 14 deletions(-) create mode 100644 .changeset/login-local-delivery.md diff --git a/.changeset/login-local-delivery.md b/.changeset/login-local-delivery.md new file mode 100644 index 0000000..511de9f --- /dev/null +++ b/.changeset/login-local-delivery.md @@ -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`. diff --git a/resources/coverage-badge.svg b/resources/coverage-badge.svg index 081bb0f..08e3978 100644 --- a/resources/coverage-badge.svg +++ b/resources/coverage-badge.svg @@ -1,5 +1,5 @@ - - coverage: 99.8% + + coverage: 99.7% @@ -17,7 +17,7 @@ coverage coverage - 99.8% - 99.8% + 99.7% + 99.7% diff --git a/src/commands/help.ts b/src/commands/help.ts index feafab9..fa7933e 100644 --- a/src/commands/help.ts +++ b/src/commands/help.ts @@ -15,7 +15,7 @@ USAGE seamless bootstrap-admin [email] [--profile ] seamless verify [--api-only] [--filter=] [--keep-up] seamless profile - seamless login [identifier] [--identifier ] [--profile ] + seamless login [identifier] [--identifier ] [--local] [--profile ] seamless whoami [--profile ] seamless logout [--all] [--profile ] seamless sessions [list] @@ -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 • Log in against a specific profile instead of the active one • Also selectable with the SEAMLESS_PROFILE environment variable diff --git a/src/commands/login.test.ts b/src/commands/login.test.ts index 04ecb1d..cd4da48 100644 --- a/src/commands/login.test.ts +++ b/src/commands/login.test.ts @@ -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)["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); diff --git a/src/commands/login.ts b/src/commands/login.ts index 1068571..ec72800 100644 --- a/src/commands/login.ts +++ b/src/commands/login.ts @@ -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 { - 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 }); @@ -19,7 +28,19 @@ export async function runLogin(args: string[]): Promise { 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) { @@ -41,6 +62,7 @@ export async function runLogin(args: string[]): Promise { const result = await completeLogin({ instanceUrl: profile.instanceUrl, identifier, + localDelivery: local, getCode: async ({ resent, channel }) => { const email = channel === "email"; const answer = await text({ @@ -72,6 +94,9 @@ export async function runLogin(args: string[]): Promise { 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( diff --git a/src/core/config.ts b/src/core/config.ts index 207e8c3..d4d89e5 100644 --- a/src/core/config.ts +++ b/src/core/config.ts @@ -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) { @@ -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}".`, ); diff --git a/src/core/loginFlow.test.ts b/src/core/loginFlow.test.ts index 5c93f58..29cd5ee 100644 --- a/src/core/loginFlow.test.ts +++ b/src/core/loginFlow.test.ts @@ -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)["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)["x-seamless-auth-delivery-mode"], + ).toBeUndefined(); + }); + it("surfaces the rate limiter when verifying a code", async () => { mockRouter({ "/login": [ diff --git a/src/core/loginFlow.ts b/src/core/loginFlow.ts index c0c358d..6caa6dc 100644 --- a/src/core/loginFlow.ts +++ b/src/core/loginFlow.ts @@ -6,6 +6,8 @@ import type { TokenBundle } from "./keychain.js"; export const EPHEMERAL_WINDOW_MS = 5 * 60 * 1000; export const DEFAULT_MAX_ATTEMPTS = 3; +const EXTERNAL_DELIVERY_HEADER = "x-seamless-auth-delivery-mode"; + export type LoginChannel = "email" | "phone"; export class LoginError extends Error { @@ -18,6 +20,7 @@ export class LoginError extends Error { export type LoginEvent = | { type: "code_sent"; channel: LoginChannel } | { type: "code_resent"; channel: LoginChannel } + | { type: "code_autofilled"; channel: LoginChannel } | { type: "verifying" } | { type: "incorrect"; attemptsLeft: number }; @@ -38,6 +41,13 @@ export interface CompleteLoginOptions { channel: LoginChannel; }) => Promise; notify?: (event: LoginEvent) => void; + /** + * Local-only escape hatch. Asks the instance for external delivery so the OTP + * comes back in the response body instead of by email/SMS, then verifies with it + * automatically. Requires the instance to run outside production with + * ALLOW_UNCREDENTIALED_DELIVERY_SECRETS=true, and should be gated to local hosts. + */ + localDelivery?: boolean; } interface StartedLogin { @@ -48,6 +58,15 @@ interface StartedLogin { deadline: number; } +function deliveryCode(data: Record | null): string | undefined { + const delivery = data?.delivery; + if (delivery && typeof delivery === "object") { + const token = (delivery as Record).token; + if (typeof token === "string" && token) return token; + } + return undefined; +} + function apiMessage(data: unknown): string | undefined { if (data && typeof data === "object") { const record = data as Record; @@ -133,15 +152,21 @@ async function startLogin( async function sendCode( instanceUrl: string, started: StartedLogin, -): Promise { + localDelivery: boolean, +): Promise { const path = started.channel === "email" ? "/otp/generate-login-email-otp" : "/otp/generate-login-phone-otp"; + const headers: Record = { + Authorization: `Bearer ${started.ephemeralToken}`, + }; + if (localDelivery) headers[EXTERNAL_DELIVERY_HEADER] = "external"; + const res = await request(instanceUrl, joinUrl(instanceUrl, path), { method: "GET", - headers: { Authorization: `Bearer ${started.ephemeralToken}` }, + headers, }); if (isRateLimited(res)) { @@ -163,6 +188,8 @@ async function sendCode( const refreshed = typeof res.data?.token === "string" ? res.data.token : undefined; if (refreshed) started.ephemeralToken = refreshed; + + return localDelivery ? deliveryCode(res.data) : undefined; } async function verifyCode( @@ -192,6 +219,15 @@ export async function completeLogin( const now = opts.now ?? (() => Date.now()); const maxAttempts = opts.maxAttempts ?? DEFAULT_MAX_ATTEMPTS; const notify = opts.notify ?? (() => {}); + const localDelivery = opts.localDelivery ?? false; + + const requireLocalCode = (code: string | undefined): void => { + if (localDelivery && !code) { + throw new LoginError( + "Local delivery is on, but the instance did not return the code. Start the auth API outside production with ALLOW_UNCREDENTIALED_DELIVERY_SECRETS=true so it returns OTP codes in the response.", + ); + } + }; let started = await startLogin(opts.instanceUrl, opts.identifier, now); const channel = started.channel; @@ -202,21 +238,30 @@ export async function completeLogin( ); } - await sendCode(opts.instanceUrl, started); + let autoCode = await sendCode(opts.instanceUrl, started, localDelivery); notify({ type: "code_sent", channel }); + requireLocalCode(autoCode); let attempt = 0; let resent = false; while (attempt < maxAttempts) { - const code = await opts.getCode({ attempt: attempt + 1, resent, channel }); + let code: string | null; + if (localDelivery && autoCode) { + code = autoCode; + autoCode = undefined; + notify({ type: "code_autofilled", channel }); + } else { + code = await opts.getCode({ attempt: attempt + 1, resent, channel }); + } resent = false; if (code === null) return null; if (now() >= started.deadline) { started = await startLogin(opts.instanceUrl, opts.identifier, now); - await sendCode(opts.instanceUrl, started); + autoCode = await sendCode(opts.instanceUrl, started, localDelivery); resent = true; notify({ type: "code_resent", channel }); + requireLocalCode(autoCode); continue; }