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" });