-
Notifications
You must be signed in to change notification settings - Fork 4
fix(auth): give each login failure its own error code and stage #442
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,6 @@ | ||
| --- | ||
| "clerk": patch | ||
| --- | ||
|
|
||
| Report why `clerk auth login` failed instead of collapsing every failure into one error. | ||
| The browser sign-in wait, OAuth provider errors, state mismatches, missing authorization codes, and callback bind failures now carry distinct error codes, and login records which step of the flow it reached. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -25,6 +25,7 @@ import { attemptAutoclaim, type AutoclaimResult } from "../../lib/autoclaim.ts"; | |
| import { openBrowser } from "../../lib/open.ts"; | ||
| import { cyan, dim } from "../../lib/color.ts"; | ||
| import { log } from "../../lib/log.ts"; | ||
| import { currentTelemetryStage, setTelemetryStage } from "../../lib/telemetry.ts"; | ||
| import { ensureFirstApplication } from "../../lib/first-application.ts"; | ||
|
|
||
| interface LoginOptions { | ||
|
|
@@ -92,6 +93,7 @@ async function performOAuthFlow(): Promise<OAuthFlowResult> { | |
| const timeoutMinutes = Math.round(AUTH_TIMEOUT_MS / 60_000); | ||
| log.info(`Waiting for authentication (timeout in ${timeoutMinutes}m)...`); | ||
|
|
||
| setTelemetryStage("awaiting_callback"); | ||
| const { code } = await withSpinner("Waiting for authentication...", async () => | ||
| authServer.waitForCallback().catch((error: unknown) => { | ||
| authServer.stop(); | ||
|
|
@@ -113,6 +115,7 @@ async function performOAuthFlow(): Promise<OAuthFlowResult> { | |
| log.debug(`credentials: could not read outgoing session — ${errorMessage(error)}`); | ||
| } | ||
|
|
||
| setTelemetryStage("token_exchange"); | ||
| const tokenResponse = await withSpinner("Completing authentication...", async () => | ||
| exchangeCodeForToken({ | ||
| code, | ||
|
|
@@ -121,6 +124,7 @@ async function performOAuthFlow(): Promise<OAuthFlowResult> { | |
| }), | ||
| ); | ||
|
|
||
| setTelemetryStage("store"); | ||
| await storeToken(createOAuthSession(tokenResponse)); | ||
|
|
||
| const userInfo = await fetchUserInfo(tokenResponse.access_token); | ||
|
|
@@ -130,13 +134,27 @@ async function performOAuthFlow(): Promise<OAuthFlowResult> { | |
| } | ||
|
|
||
| export async function login(options: LoginOptions = {}): Promise<UserInfo> { | ||
| // `init` and `link` call this mid-flow and share the one process-global | ||
| // telemetry stage. Login's own markers are worth having while it runs, but | ||
| // on a clean return the caller's stage comes back so the rest of *their* | ||
| // work isn't reported as `done`. On a throw the login stage stands: that is | ||
| // genuinely where the run stopped. | ||
| const callerStage = currentTelemetryStage(); | ||
| const userInfo = await runLogin(options); | ||
| if (callerStage) setTelemetryStage(callerStage); | ||
| return userInfo; | ||
| } | ||
|
|
||
| async function runLogin(options: LoginOptions = {}): Promise<UserInfo> { | ||
| const { showNextSteps = true, yes } = options; | ||
| intro("Signing in"); | ||
| setTelemetryStage("session_check"); | ||
| const existingSession = await withSpinner("Checking session...", async () => | ||
| getExistingSession(), | ||
| ); | ||
|
|
||
| if (existingSession && !isHuman()) { | ||
| setTelemetryStage("done"); | ||
| log.success(`Logged in as ${existingSession.email}`); | ||
| const claimResult = await handleAutoclaim(process.cwd()); | ||
| if (showNextSteps) { | ||
|
|
@@ -180,9 +198,11 @@ export async function login(options: LoginOptions = {}): Promise<UserInfo> { | |
|
|
||
| // Best-effort: ensure the user has at least one application so downstream | ||
| // commands (clerk link, clerk init) have something to operate on. | ||
| setTelemetryStage("first_application"); | ||
| await withSpinner("Setting up your default application...", async () => ensureFirstApplication()); | ||
|
|
||
| bar(); | ||
| setTelemetryStage("done"); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
The concrete failure:
That inverts what the stage field is for. The same hazard applies to the whole flat Suggested fix — restore the caller's stage on a clean returnRestoring only on a clean return keeps the useful half: a run that dies inside the browser wait still reports In /** Read the stage a caller had set, so a nested flow can hand it back. */
export function currentTelemetryStage(): TelemetryStage | null {
return context?.stage ?? null;
}In export async function login(options: LoginOptions = {}): Promise<UserInfo> {
// `init`, `link`, and `doctor` call this mid-flow and share the one
// process-global stage. Its own markers are worth having while it runs, but
// on a clean return the caller's stage comes back so the rest of *their*
// work isn't reported as `done`. On a throw the login stage stands: that is
// genuinely where the run stopped.
const callerStage = currentTelemetryStage();
const userInfo = await runLogin(options);
if (callerStage) setTelemetryStage(callerStage);
return userInfo;
}Gating the stage calls on login being the invoked command works too, and is more explicit — but it needs a signal login doesn't currently have ( Sent from Claude
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fixed in bf122db — took the wrapper: |
||
| log.success(`Logged in as ${userInfo.email}`); | ||
|
|
||
| const claimResult = await handleAutoclaim(process.cwd()); | ||
|
|
||
| Original file line number | Diff line number | Diff line change | ||||||
|---|---|---|---|---|---|---|---|---|
|
|
@@ -4,6 +4,7 @@ | |||||||
| */ | ||||||||
|
|
||||||||
| import { AUTH_TIMEOUT_MS, CALLBACK_PATH } from "./constants.ts"; | ||||||||
| import { CliError, ERROR_CODE, errorMessage } from "./errors.ts"; | ||||||||
| import { observeHostCapabilityFailure } from "./host-execution.ts"; | ||||||||
| import { log } from "./log.ts"; | ||||||||
| import { whileAwaitingUser } from "./signals.ts"; | ||||||||
|
|
@@ -188,8 +189,9 @@ export function startAuthServer(expectedState: string): AuthServerResult { | |||||||
| const timeout = setTimeout(() => { | ||||||||
| log.debug(`auth-server: timed out after ${AUTH_TIMEOUT_MS}ms`); | ||||||||
| rejectCallback( | ||||||||
| new Error( | ||||||||
| new CliError( | ||||||||
| "Authentication timed out. Run `clerk auth login` to try again — if your browser did not open, copy the printed URL into any browser on this machine.", | ||||||||
| { code: ERROR_CODE.AUTH_TIMEOUT }, | ||||||||
| ), | ||||||||
| ); | ||||||||
| // `stop()` is a promise nothing can wait on: the login flow has already | ||||||||
|
|
@@ -213,7 +215,11 @@ export function startAuthServer(expectedState: string): AuthServerResult { | |||||||
| if (error) { | ||||||||
| const description = url.searchParams.get("error_description") || error; | ||||||||
| log.debug(`auth-server: OAuth error in callback — ${error}: ${description}`); | ||||||||
| rejectCallback(new Error(`OAuth error: ${description}`)); | ||||||||
| rejectCallback( | ||||||||
| new CliError(`OAuth error: ${description}`, { | ||||||||
| code: ERROR_CODE.OAUTH_PROVIDER_ERROR, | ||||||||
| }), | ||||||||
| ); | ||||||||
| clearTimeout(timeout); | ||||||||
| setTimeout(() => void server?.stop(), 100); | ||||||||
| return new Response(ERROR_HTML(description), { | ||||||||
|
|
@@ -223,7 +229,11 @@ export function startAuthServer(expectedState: string): AuthServerResult { | |||||||
|
|
||||||||
| if (state !== expectedState) { | ||||||||
| log.debug(`auth-server: state mismatch (expected=${expectedState}, got=${state})`); | ||||||||
| rejectCallback(new Error("Invalid state parameter. Possible CSRF attack.")); | ||||||||
| rejectCallback( | ||||||||
| new CliError("Invalid state parameter. Possible CSRF attack.", { | ||||||||
| code: ERROR_CODE.OAUTH_STATE_MISMATCH, | ||||||||
| }), | ||||||||
| ); | ||||||||
| clearTimeout(timeout); | ||||||||
| setTimeout(() => void server?.stop(), 100); | ||||||||
| return new Response(ERROR_HTML("Invalid state parameter."), { | ||||||||
|
|
@@ -234,7 +244,11 @@ export function startAuthServer(expectedState: string): AuthServerResult { | |||||||
|
|
||||||||
| if (!code) { | ||||||||
| log.debug("auth-server: callback received with no authorization code"); | ||||||||
| rejectCallback(new Error("No authorization code received.")); | ||||||||
| rejectCallback( | ||||||||
| new CliError("No authorization code received.", { | ||||||||
| code: ERROR_CODE.OAUTH_NO_CODE, | ||||||||
| }), | ||||||||
| ); | ||||||||
| clearTimeout(timeout); | ||||||||
| setTimeout(() => void server?.stop(), 100); | ||||||||
| return new Response(ERROR_HTML("No authorization code received."), { | ||||||||
|
|
@@ -266,7 +280,17 @@ export function startAuthServer(expectedState: string): AuthServerResult { | |||||||
| target: "127.0.0.1:0", | ||||||||
| label: CALLBACK_PATH, | ||||||||
| }); | ||||||||
| throw error; | ||||||||
| // A sandbox or firewall that forbids binding loopback fails every login on | ||||||||
| // the machine; it is a distinct condition from anything the user did. | ||||||||
| log.debug( | ||||||||
| `auth-server: bind failed — ${error instanceof Error ? (error.stack ?? error.message) : String(error)}`, | ||||||||
| ); | ||||||||
| throw new CliError( | ||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested fix — keep the original recoverable under --verboseEvery other branch in this file already debug-logs before rejecting; this one is the outlier.
Suggested change
Sent from Claude
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fixed in bf122db — original error (stack included) debug-logged before the CliError wraps it. |
||||||||
| `Could not start the local sign-in callback server: ${errorMessage(error)}`, | ||||||||
| { | ||||||||
| code: ERROR_CODE.CALLBACK_BIND_FAILED, | ||||||||
| }, | ||||||||
| ); | ||||||||
| } | ||||||||
|
|
||||||||
| const activeServer = server; | ||||||||
|
|
||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
"store"is the last stage set beforeperformOAuthFlowreturns, so it coversstoreToken,fetchUserInfo,setAuth, and then back inlogin():revokeTokenfor the superseded session andensureFirstApplication.ensureFirstApplicationis the one that matters — it's a PLAPI round trip that creates an application, and it's the slowest and most failure-prone step in the tail of this flow. A user whose login dies there producesstage: "store", which reads as a credential-store problem and points debugging at the keychain instead of the API.Suggested fix — give the app-creation step its own marker
In
lib/telemetry.ts:// `clerk auth login` | "session_check" | "awaiting_callback" | "token_exchange" | "store" + | "first_application"In
commands/auth/login.ts:// Best-effort: ensure the user has at least one application so downstream // commands (clerk link, clerk init) have something to operate on. + setTelemetryStage("first_application"); await withSpinner("Setting up your default application...", async () => ensureFirstApplication());Sent from Claude
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Fixed in bf122db —
first_applicationstage added beforeensureFirstApplication.