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
10 changes: 10 additions & 0 deletions docs/product/output-conventions.md
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,16 @@ No current MVP command uses `verify` or `inspect`, but new commands must still c

### Workspace session identity

`auth login --ui-context prisma-plugin` changes only the browser completion
guidance for an invocation started by the Prisma plugin. Its success page says
“You’re connected to Prisma. Return to your ChatGPT conversation.” Its failure
page says “Sign-in couldn’t be completed. Return to your ChatGPT conversation to
try again.” The plugin completion page omits the skills-install command.
Omitting the flag preserves the terminal guidance and skills-install prompt.
The only accepted explicit value is `prisma-plugin`; other values fail argument
validation before login starts. The context is never inferred or stored and
does not change OAuth, consent, scopes, credential storage, or terminal output.

Each `auth login` authorizes one workspace and stores one local session. Two
sessions can belong to different Prisma users. `auth workspace list` shows the
sessions authorized on this machine. It is not the full list of workspaces the
Expand Down
38 changes: 29 additions & 9 deletions packages/cli/src/auth/login.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ export class AuthError extends Error {
}

export interface LoginOptions {
/** Presentation only, scoped to this invocation; never stored with tokens. */
uiContext?: "prisma-plugin";
tokenStorage?: TokenStorage;
clientId?: string;
apiBaseUrl?: string;
Expand Down Expand Up @@ -115,15 +117,15 @@ export async function login(options: LoginOptions = {}): Promise<void> {
// success page anyway so a late browser callback isn't left dangling.
const workspaceName = await state.resolveWorkspaceName();
res.setHeader("Content-Type", "text/html; charset=utf-8");
res.end(renderSuccessPage(workspaceName));
res.end(renderSuccessPage(workspaceName, options.uiContext));
return;
}

try {
await completeOnce(url);
const workspaceName = await state.resolveWorkspaceName();
res.setHeader("Content-Type", "text/html; charset=utf-8");
res.end(renderSuccessPage(workspaceName));
res.end(renderSuccessPage(workspaceName, options.uiContext));
settle(resolve);
} catch (error) {
res.statusCode = 400;
Expand All @@ -133,7 +135,11 @@ export async function login(options: LoginOptions = {}): Promise<void> {
// process does not control. The operator still sees the real
// error: it is what this promise rejects with.
res.setHeader("Content-Type", "text/plain; charset=utf-8");
res.end("Sign-in could not be completed. Return to your terminal.");
res.end(
options.uiContext === "prisma-plugin"
? "Sign-in couldn’t be completed. Return to your ChatGPT conversation to try again."
: "Sign-in could not be completed. Return to your terminal.",
);
settle(() => reject(error));
return;
}
Expand Down Expand Up @@ -401,10 +407,16 @@ class LoginState {
}
}

function renderSuccessPage(workspaceName: string | null): string {
const body = workspaceName
function renderSuccessPage(
workspaceName: string | null,
uiContext?: LoginOptions["uiContext"],
): string {
let body = workspaceName
? `Your terminal is now connected to your ${escapeHtml(workspaceName)} workspace. Head back to your terminal to continue.`
: "Your terminal is now connected to your Prisma workspace. Head back to your terminal to continue.";
if (uiContext === "prisma-plugin") {
body = "You’re connected to Prisma. Return to your ChatGPT conversation.";
}

return `<!doctype html>
<html lang="en">
Expand Down Expand Up @@ -580,7 +592,10 @@ function renderSuccessPage(workspaceName: string | null): string {
<main>
<h1>You're all set.</h1>
<p>${body}</p>
<section class="skills">
${
uiContext === "prisma-plugin"
? ""
: `<section class="skills">
<div class="skills-lead">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M9.937 15.5A2 2 0 0 0 8.5 14.063l-6.135-1.582a.5.5 0 0 1 0-.962L8.5 9.936A2 2 0 0 0 9.937 8.5l1.582-6.135a.5.5 0 0 1 .963 0L14.063 8.5A2 2 0 0 0 15.5 9.937l6.135 1.581a.5.5 0 0 1 0 .964L15.5 14.063a2 2 0 0 0-1.437 1.437l-1.582 6.135a.5.5 0 0 1-.963 0z"/><path d="M20 3v4"/><path d="M22 5h-4"/></svg>
Using an AI coding agent? Add the Prisma skills:
Expand All @@ -593,9 +608,13 @@ function renderSuccessPage(workspaceName: string | null): string {
</button>
</div>
<span class="visually-hidden skills-status" role="status"></span>
</section>
</section>`
}
</main>
<script>
${
uiContext === "prisma-plugin"
? ""
: `<script>
(() => {
const command = "npx skills add prisma/skills";
const button = document.querySelector(".copy");
Expand Down Expand Up @@ -624,7 +643,8 @@ function renderSuccessPage(workspaceName: string | null): string {
}, 2000);
});
})();
</script>
</script>`
}
</body>
</html>`;
}
Expand Down
5 changes: 3 additions & 2 deletions packages/cli/src/auth/operations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import type {
} from "@prisma/management-api-sdk";
import type { AuthStateResult } from "../types/auth";
import { authenticatedManagementApiClient } from "./guard";
import { AuthError, login } from "./login";
import { AuthError, type LoginOptions, login } from "./login";
import { FileTokenStorage } from "./token-storage";

const WORKSPACE_SUB_PREFIX = "workspace:";
Expand Down Expand Up @@ -91,14 +91,15 @@ class ThrowawayTokenStorage implements TokenStorage {
export async function performLogin(
env: NodeJS.ProcessEnv,
signal?: AbortSignal,
options?: { onVerificationUrl?: (url: string) => void },
options?: Pick<LoginOptions, "onVerificationUrl" | "uiContext">,
): Promise<Credential> {
const tokenStorage = new ThrowawayTokenStorage();
await login({
tokenStorage,
env,
signal,
onVerificationUrl: options?.onVerificationUrl,
uiContext: options?.uiContext,
});

const tokens = tokenStorage.tokens;
Expand Down
13 changes: 12 additions & 1 deletion packages/cli/src/commands/auth/login.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import {
credentialWorkspaceId,
defineCommand,
flag,
type Presentations,
type Session,
} from "@prisma/cli-engine";
Expand Down Expand Up @@ -120,20 +121,30 @@ function presentationsFor(

export const authLoginCommand = defineCommand({
managesCredentials: true,
args: {
flags: {
uiContext: flag.enum({
values: ["prisma-plugin"],
brief:
"Show browser completion guidance for the Prisma plugin in ChatGPT (default: terminal guidance)",
}),
},
},
help: {
summary: "Log in to your Prisma platform account",
description:
"Opens a browser sign-in and stores a session for one workspace, the account-level container that holds your Projects. Run it again to add a session for another workspace; 'auth workspace use' switches between stored sessions. In CI or other non-interactive environments, skip login and set PRISMA_SERVICE_TOKEN instead.",
examples: ["auth login"],
},
handler: async (_args, ctx) => {
handler: async (args, ctx) => {
// A blank service token is the single blank-token error, raised
// before the browser opens rather than after a credential is minted.
const environmentSession = environmentCredentialInForce(ctx.env);
ctx.report({ kind: "step-started", step: LOGIN_STEP });
let session: Session;
try {
const credential = await performLogin(ctx.env, ctx.signal, {
uiContext: args.flags.uiContext,
onVerificationUrl: (url) =>
ctx.report({ kind: "endpoint", name: "verification", url }),
});
Expand Down
72 changes: 69 additions & 3 deletions packages/cli/tests/auth-login.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,10 @@ afterEach(() => {
});

describe("auth login callback", () => {
it("reports OAuth denial as an expected refusal without persisting credentials or reflecting callback text", async () => {
it.each([
undefined,
"prisma-plugin",
] as const)("reports OAuth denial without persisting credentials or reflecting callback text (UI context: %s)", async (uiContext) => {
const tokenStorage: TokenStorage = {
getTokens: vi.fn().mockResolvedValue(null),
setTokens: vi.fn(),
Expand All @@ -18,6 +21,7 @@ describe("auth login callback", () => {
const { login } = await import("../src/auth/login");
await expect(
login({
uiContext,
hostname: "127.0.0.1",
tokenStorage,
openUrl: async (authorizationUrl) => {
Expand All @@ -33,8 +37,10 @@ describe("auth login callback", () => {
);
const response = await fetch(callback);
expect(response.status).toBe(400);
expect(await response.text()).not.toContain(
"private-callback-detail",
expect(await response.text()).toBe(
uiContext === "prisma-plugin"
? "Sign-in couldn’t be completed. Return to your ChatGPT conversation to try again."
: "Sign-in could not be completed. Return to your terminal.",
);
},
}),
Expand All @@ -53,6 +59,27 @@ describe("auth login callback", () => {
expect(result.body).toContain('<meta charset="utf-8">');
});

it.each([
'Acme <Corp> & "Team"',
undefined,
])("renders plugin guidance without terminal or installation instructions (workspace: %s)", async (workspaceName) => {
const result = await requestSuccessPage({
uiContext: "prisma-plugin",
workspaceName,
...(workspaceName ? {} : { workspaceLookupError: new Error("offline") }),
});

expect(result.body).toContain(
"You’re connected to Prisma. Return to your ChatGPT conversation.",
);
expect(result.body).not.toContain("terminal");
expect(result.body).not.toContain("npx skills");
expect(result.body).not.toContain("<script>");
expect(result.body).not.toContain('<section class="skills">');
expect(result.body).not.toContain('Acme <Corp> & "Team"');
expect(result.loginScope).toBe("workspace:admin offline_access");
});

it("requests the supported Management API OAuth scopes", async () => {
const result = await requestSuccessPage({ workspaceName: "Acme Corp" });

Expand Down Expand Up @@ -144,11 +171,13 @@ describe("auth login callback", () => {
hostname: "127.0.0.1",
tokenStorage,
signal: controller.signal,
uiContext: "prisma-plugin",
openUrl: () => {
controller.abort(reason);
},
}),
).rejects.toBe(reason);
expect(tokenStorage.setTokens).not.toHaveBeenCalled();
});

it("rejects when the command signal aborts during workspace lookup", async () => {
Expand Down Expand Up @@ -206,6 +235,7 @@ describe("auth login callback", () => {
});

async function requestSuccessPage(options: {
uiContext?: "prisma-plugin";
workspaceName?: string;
workspaceLookupError?: Error;
}): Promise<{
Expand Down Expand Up @@ -273,6 +303,7 @@ async function requestSuccessPage(options: {
const { login } = await import("../src/auth/login");

await login({
uiContext: options.uiContext,
hostname: "127.0.0.1",
tokenStorage,
openUrl: async () => {
Expand All @@ -290,6 +321,37 @@ async function requestSuccessPage(options: {
}

describe("auth login remote paste flow", () => {
it("keeps the plugin attempt alive after a browser-launch failure so the emitted link can complete it", async () => {
let callback: Promise<Response> | undefined;
const onVerificationUrl = vi.fn();
const result = await runLogin({
uiContext: "prisma-plugin",
ttyInput: true,
onVerificationUrl,
openUrl: (redirectUri) => {
// Simulate opening the emitted authorization link separately, then
// returning to this same listener. No callback URL is pasted.
callback = new Promise((resolve, reject) => {
setImmediate(() => {
fetch(`${redirectUri}?code=code_123&state=state_123`).then(
resolve,
reject,
);
});
});
throw new Error("no browser available");
},
});

expect(onVerificationUrl).toHaveBeenCalledExactlyOnceWith(
"https://auth.example.test/login",
);
expect(result.handleCallbackCalls).toBe(1);
expect(await (await callback)?.text()).toContain(
"You’re connected to Prisma. Return to your ChatGPT conversation.",
);
});

it("ends login when a pasted callback denies authorization instead of retrying", async () => {
await expect(
runLogin({
Expand Down Expand Up @@ -402,6 +464,8 @@ const PASTE_CALLBACK_URL =
"http://localhost:9999/auth/callback?code=code_123&state=state_123";

async function runLogin(options: {
uiContext?: "prisma-plugin";
onVerificationUrl?: (url: string) => void;
ttyInput: boolean;
openUrl: (redirectUri: string) => Promise<unknown> | unknown;
pasteLines?: string[];
Expand Down Expand Up @@ -475,6 +539,8 @@ async function runLogin(options: {

await login({
hostname: "127.0.0.1",
uiContext: options.uiContext,
onVerificationUrl: options.onVerificationUrl,
tokenStorage,
input,
output,
Expand Down
45 changes: 45 additions & 0 deletions packages/cli/tests/auth-ops.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,57 @@ import path from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";

afterEach(() => {
vi.doUnmock("../src/auth/login");
vi.doUnmock("../src/auth/token-storage");
vi.doUnmock("../src/auth/guard");
vi.resetModules();
vi.restoreAllMocks();
});

describe("performLogin", () => {
it.each([
undefined,
"prisma-plugin",
] as const)("propagates UI context (%s) to login without adding it to the credential", async (uiContext) => {
const token = encodeJwt({ workspace_id: "plugin_test" });
const login = vi.fn().mockImplementation(async (options) => {
options.onVerificationUrl?.("https://auth.example.test/authorize");
await options.tokenStorage.setTokens({
accessToken: token,
refreshToken: "test-refresh-token",
});
});
vi.doMock("../src/auth/login", async (importOriginal) => ({
...(await importOriginal<typeof import("../src/auth/login")>()),
login,
}));
const { performLogin } = await import("../src/auth/operations");
const onVerificationUrl = vi.fn();
const signal = new AbortController().signal;

const credential = await performLogin({}, signal, {
uiContext,
onVerificationUrl,
});

expect(login).toHaveBeenCalledWith(
expect.objectContaining({
uiContext,
onVerificationUrl,
signal,
}),
);
expect(onVerificationUrl).toHaveBeenCalledWith(
"https://auth.example.test/authorize",
);
expect(credential).toEqual({
token,
refreshToken: "test-refresh-token",
expiresAt: undefined,
});
});
});

function encodeJwt(claims: Record<string, unknown>): string {
const payload = Buffer.from(JSON.stringify(claims), "utf8").toString(
"base64url",
Expand Down
Loading
Loading