Skip to content

feat(cli): autorun login for authed commands - #118

Merged
bespoyasov merged 10 commits into
mainfrom
feat/autorun-login
Oct 19, 2026
Merged

bespoyasov merged 10 commits into
mainfrom
feat/autorun-login

Conversation

@bespoyasov

Copy link
Copy Markdown
Collaborator

Summary

  • Auto-login for authenticated commands: When a user runs any authenticated CLI command (e.g. confidence flags list) without being logged in, the CLI now automatically opens the browser for OAuth instead of just printing an error. In non-interactive environments (CI, piped), the existing error messages are preserved.
  • Token refresh before login: Expired tokens with a refresh token available are silently refreshed — no browser pop-up needed. Falls back to full login only if refresh fails.
  • Refactored JWT helpers: Split validateToken into focused functions (isValidToken, extractWorkspace, extractRegion, extractOrganization) so consumers only use what they need.

Details

New three-tier token resolution in packages/cli/src/utils/auth.ts:

  1. Valid persisted token → return immediately
  2. Expired + refresh token → try silent refresh
  3. Interactive TTY → open browser for OAuth login; non-interactive → error message

tryLoadToken is the shared helper used by both withAuth() (general API commands, gated on isInteractive()) and resolveAuthToken (inherently interactive commands like MCP install/migrate, always attempts browser auth).

Test plan

  • New auth.test.ts with 8 tests covering all resolution paths (valid, refresh, interactive login, non-interactive fallback, failure, profile forwarding)
  • All 535 existing tests pass unchanged (non-interactive guard preserves current behavior)
  • pnpm qa clean (typecheck + lint + test)
  • Manual: confidence flags list without login → opens browser, completes command after auth
  • Manual: confidence flags list | cat without login → shows "Not logged in" error

@bespoyasov
bespoyasov marked this pull request as draft October 19, 2026 08:05
@bespoyasov
bespoyasov marked this pull request as ready for review October 19, 2026 08:11

@filipmyllari filipmyllari left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review notes (AI-assisted). Inline comments below, plus one that doesn't map to diff lines:

  • Minor — packages/quickstart/__tests__/e2e/stale-auth.e2e.ts:114: this comment still mentions validateToken, which this PR removes.

Comment on lines +48 to +52
const result = await authenticate({
mode: 'login',
profile,
onUrl: (url) => message(`If the browser did not open, visit:\n${url}`),
});

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

authenticate() is called with no signal and no timeout. If the user closes the browser tab or never finishes the login, any withAuth command hangs forever with the callback server still listening. Before this PR the command just failed fast. Pass an AbortSignal.timeout(...) and say in the message that Ctrl+C cancels.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added a default argument in authenticate with a signal

Comment on lines +20 to 25
try {
const refreshed = await refreshAccessToken(profile);
return { token: refreshed.accessToken, expired: false };
} catch {
return { token: null, expired: true };
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Every refresh failure counts as "expired", including network errors and timeouts. On flaky wifi or a VPN drop, a command that has a perfectly good refresh token opens the browser for a full login, which then probably fails too. Only fall through to login on an auth rejection (a 4xx, or no refresh token stored). For anything else, fail with the error.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added two separate new cases for expired and malformed token; otherwise, re-throws

Comment thread packages/cli/src/utils/auth.ts Outdated
Comment on lines +56 to +57
} catch {
fail('Login failed. Try running "confidence login" to re-authenticate.');

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The catch drops the real error. Port-in-use, a state mismatch and a token-exchange failure all show up as "Login failed. Try running confidence login", which is the same flow that just failed. Include err.message.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added

Comment thread packages/cli/src/commands/whoami.ts Outdated
Comment on lines +15 to +16

const token = loadPersistedToken(profile);
if (!token) {
fail('Not logged in. Run "confidence login" first.');
return;
}

const validation = validateToken(token);
if (!validation.valid) {
fail('Token is expired. Run "confidence login" to re-authenticate.');
return;
}
const token = await tryResolveAuth(profile);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

whoami used to be a read-only status check. Now an expired or missing token triggers a refresh (which writes credentials) or opens a browser login. People and scripts use whoami to ask "am I logged in?", so it should probably keep using tryLoadAuthToken and report the status without starting a login.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

At first I thought I would disagree, but the longer I think about, the more I'm agreeing with this 😃

Comment thread packages/cli/src/utils/auth.ts Outdated
Comment on lines +42 to +54
message(
loaded.expired
? 'Session expired. Opening browser to re-authenticate...'
: 'Not logged in to Confidence. Opening browser to authenticate...',
);

const result = await authenticate({
mode: 'login',
profile,
onUrl: (url) => message(`If the browser did not open, visit:\n${url}`),
});

message(`Authenticated as ${result.workspace ?? 'unknown'} (${result.region})`);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The progress messages go through message(), which writes to stdout. The isInteractive() change protects pipes, but confidence flags list --output json run in a terminal still gets "Session expired… / Authenticated as…" mixed into its machine-readable output. Write these with error() (stderr) instead.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added another gated helper for interactive-focused messages, hint

if (valid) return existing;
}
const { token } = await tryLoadAuthToken(opts?.profile);
if (token) return token;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

resolveAuthToken now refreshes silently before falling back to authenticate. That's fine, but forceNew is the only way to skip it now. Check that the MCP setup callers expect the refresh to write to the credentials file.

fail('Not logged in. Run "confidence login" first.');
return null;
if (!token) return { token: null, expired: false };
if (isValid(token)) return { token, expired: false };

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor: a malformed token fails isValid, then the refresh fails, and the user is told "Token expired" or "Session expired", which is misleading. Treat an undecodable token the same as no token.

bespoyasov and others added 4 commits October 19, 2026 14:26
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Transient failures (timeout, DNS, VPN drop) now propagate instead of
being swallowed as "expired". A hasExpiredSession helper checks the
error cause so tryLoadAuthToken only triggers the browser login flow
when the refresh token is genuinely invalid.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
… output

- whoami is now read-only: uses tryLoadAuthToken instead of
  tryResolveAuth so it never opens a browser login
- decodeJwtPayload throws with { cause: MALFORMED_TOKEN_ERR } so
  corrupt tokens are treated as "not logged in", not "expired"
- authenticate() defaults to a 120s AbortSignal timeout so callers
  can't accidentally hang
- Add hint() helper that writes to stdout only in interactive mode,
  replacing message() for auth progress so piped/JSON output stays clean
- MCP resolveAuthToken catches tryLoadAuthToken throws and adds timeout

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@bespoyasov

Copy link
Copy Markdown
Collaborator Author

Thanks, @filipmyllari! Round two? 🙃

@filipmyllari filipmyllari left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Beautiful 🙌🏻

@bespoyasov
bespoyasov merged commit 7af60eb into main Oct 19, 2026
6 checks passed
@bespoyasov
bespoyasov deleted the feat/autorun-login branch October 19, 2026 13:21
@github-actions github-actions Bot mentioned this pull request Oct 19, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants