From a36de0188dc01dbee18eeec236485ebeba14d376 Mon Sep 17 00:00:00 2001 From: Aman Varshney Date: Thu, 10 Sep 2026 14:20:49 +0530 Subject: [PATCH 1/2] fix(auth): report OAuth denial as an expected refusal Signed-off-by: Aman Varshney --- docs/product/error-conventions.md | 6 +++++ docs/reference/error-reference.md | 4 +++ packages/cli/src/auth/login.ts | 17 ++++++++++++ packages/cli/tests/auth-login.test.ts | 37 +++++++++++++++++++++++++++ 4 files changed, 64 insertions(+) diff --git a/docs/product/error-conventions.md b/docs/product/error-conventions.md index 3e6c1d16..bd50c744 100644 --- a/docs/product/error-conventions.md +++ b/docs/product/error-conventions.md @@ -45,6 +45,12 @@ Expected failures should: ### Operational Error +An OAuth `access_denied` callback during `auth login` is an expected authorization +refusal, reported as `AUTH.LOGIN_DENIED`, not `CLI.INTERNAL_ERROR`. It does not +create or clear stored sessions. The CLI suggests signing in again only if the +user intends to authorize access. Callback descriptions are not reflected into +diagnostics because they are untrusted input. + An expected external fault, not a product bug. Examples: diff --git a/docs/reference/error-reference.md b/docs/reference/error-reference.md index eb67b10b..75964ad0 100644 --- a/docs/reference/error-reference.md +++ b/docs/reference/error-reference.md @@ -34,6 +34,10 @@ A credential's `workspace_id` claim disagrees with the workspace it is being sto `prisma auth login` completed the browser sign-in but the minted credential carries no `workspace_id` claim, so no workspace session can be keyed by it. The fix is to sign in again and pick a workspace in the browser. Meta: none. +### AUTH.LOGIN_DENIED + +The OAuth callback reported `access_denied`. Authorization was not granted; this is an expected refusal, not a CLI crash. No session is created or cleared. Run `prisma auth login` again only to grant access intentionally. The callback description is not echoed. Meta: none. + ### AUTH.NO_SESSION_FOR_WORKSPACE A workspace reference matched none of the stored workspace sessions — raised by the command-side ref resolver behind `prisma auth workspace use` and `prisma auth workspace logout` (exact id match first, then case-insensitive name match), and by the credential managers when a session operation names a workspace with no stored record. Sessions are created only by `prisma auth login`, so the suggested fix is to sign in and pick that workspace in the browser; the workspace reference appears in the message, not in meta. Meta: none. diff --git a/packages/cli/src/auth/login.ts b/packages/cli/src/auth/login.ts index c19a87d2..5a04e441 100644 --- a/packages/cli/src/auth/login.ts +++ b/packages/cli/src/auth/login.ts @@ -4,6 +4,7 @@ import http from "node:http"; import type { AddressInfo } from "node:net"; import readline from "node:readline/promises"; import type { Readable, Writable } from "node:stream"; +import { CliStructuredError } from "@prisma/cli-engine/protocol"; import { createManagementApiSdk, @@ -12,6 +13,7 @@ import { type TokenStorage, } from "@prisma/management-api-sdk"; import open from "open"; +import { CLI_NAME } from "../cli-name"; import { CLIENT_ID, getApiBaseUrl } from "./client"; import { FileTokenStorage } from "./token-storage"; @@ -333,6 +335,21 @@ class LoginState { const params = url.searchParams; const error = params.get("error"); + if (error === "access_denied") { + throw new CliStructuredError( + "AUTH.LOGIN_DENIED", + "Sign-in was not authorized.", + { + nextActions: [ + { + kind: "run-command", + label: "Sign in again if you want to grant access", + command: `${CLI_NAME} auth login`, + }, + ], + }, + ); + } if (error) { const desc = params.get("error_description"); throw new AuthError(desc ? `${error}: ${desc}` : error); diff --git a/packages/cli/tests/auth-login.test.ts b/packages/cli/tests/auth-login.test.ts index 82444ff2..e5061c74 100644 --- a/packages/cli/tests/auth-login.test.ts +++ b/packages/cli/tests/auth-login.test.ts @@ -9,6 +9,43 @@ afterEach(() => { }); describe("auth login callback", () => { + it("reports OAuth denial as an expected refusal without persisting credentials or reflecting callback text", async () => { + const tokenStorage: TokenStorage = { + getTokens: vi.fn().mockResolvedValue(null), + setTokens: vi.fn(), + clearTokens: vi.fn(), + }; + const { login } = await import("../src/auth/login"); + await expect( + login({ + hostname: "127.0.0.1", + tokenStorage, + openUrl: async (authorizationUrl) => { + const redirect = new URL(authorizationUrl).searchParams.get( + "redirect_uri", + ); + if (redirect === null) throw new Error("Missing OAuth redirect_uri"); + const callback = new URL(redirect); + callback.searchParams.set("error", "access_denied"); + callback.searchParams.set( + "error_description", + "private-callback-detail", + ); + const response = await fetch(callback); + expect(response.status).toBe(400); + expect(await response.text()).not.toContain( + "private-callback-detail", + ); + }, + }), + ).rejects.toMatchObject({ + code: "AUTH.LOGIN_DENIED", + message: "Sign-in was not authorized.", + }); + expect(tokenStorage.setTokens).not.toHaveBeenCalled(); + expect(tokenStorage.clearTokens).not.toHaveBeenCalled(); + }); + it("serves the success page as UTF-8 HTML", async () => { const result = await requestSuccessPage({ workspaceName: "Acme Corp" }); From a37a430d0601d12c2105ccc37c0ad0a1d055ffbf Mon Sep 17 00:00:00 2001 From: Aman Varshney Date: Mon, 14 Sep 2026 21:33:54 +0530 Subject: [PATCH 2/2] fix(auth): propagate denied pasted login callbacks Signed-off-by: Aman Varshney --- docs/product/error-conventions.md | 2 ++ packages/cli/src/auth/login.ts | 1 + packages/cli/tests/auth-login.test.ts | 16 ++++++++++++++++ 3 files changed, 19 insertions(+) diff --git a/docs/product/error-conventions.md b/docs/product/error-conventions.md index bd50c744..1f8503dd 100644 --- a/docs/product/error-conventions.md +++ b/docs/product/error-conventions.md @@ -50,6 +50,8 @@ refusal, reported as `AUTH.LOGIN_DENIED`, not `CLI.INTERNAL_ERROR`. It does not create or clear stored sessions. The CLI suggests signing in again only if the user intends to authorize access. Callback descriptions are not reflected into diagnostics because they are untrusted input. +Both browser redirects and pasted callback URLs end login with that error; +an explicit denial must not reopen the paste prompt. An expected external fault, not a product bug. diff --git a/packages/cli/src/auth/login.ts b/packages/cli/src/auth/login.ts index 5a04e441..d2f90b90 100644 --- a/packages/cli/src/auth/login.ts +++ b/packages/cli/src/auth/login.ts @@ -232,6 +232,7 @@ async function tryCompletePastedCallback( await options.complete(url); return true; } catch (error) { + if (error instanceof CliStructuredError) throw error; const message = error instanceof Error ? error.message : String(error); options.output.write( `Sign-in didn't complete (${message}). Paste the callback URL to try again.\n`, diff --git a/packages/cli/tests/auth-login.test.ts b/packages/cli/tests/auth-login.test.ts index e5061c74..667a4a96 100644 --- a/packages/cli/tests/auth-login.test.ts +++ b/packages/cli/tests/auth-login.test.ts @@ -290,6 +290,22 @@ async function requestSuccessPage(options: { } describe("auth login remote paste flow", () => { + it("ends login when a pasted callback denies authorization instead of retrying", async () => { + await expect( + runLogin({ + ttyInput: true, + openUrl: () => {}, + pasteLines: [ + "http://localhost:9999/auth/callback?error=access_denied&error_description=private-callback-detail", + PASTE_CALLBACK_URL, + ], + }), + ).rejects.toMatchObject({ + code: "AUTH.LOGIN_DENIED", + message: "Sign-in was not authorized.", + }); + }); + it("completes the token exchange via a pasted callback URL on a TTY", async () => { const result = await runLogin({ ttyInput: true,