Skip to content

feat(act): add OAuth 2.1 PKCE support#2

Merged
yaonyan merged 4 commits into
mainfrom
feat/act-oauth-pkce
May 22, 2026
Merged

feat(act): add OAuth 2.1 PKCE support#2
yaonyan merged 4 commits into
mainfrom
feat/act-oauth-pkce

Conversation

@yaonyan
Copy link
Copy Markdown
Collaborator

@yaonyan yaonyan commented May 22, 2026

Summary

  • src/oauth.ts (new): full OAuth 2.1 PKCE login flow — local HTTP callback server, cross-platform browser opener, headless stdin fallback, automatic token refresh, disk-backed state at ~/.config/one/oauth-state.json
  • daemon.ts: adds auth?: "oauth" field to OneActMcpServerConfig; stripped in normalizeMcpServersForRuntime so it never leaks to the transport layer
  • act.ts:
    • injectOAuthHeaders() auto-fetches/refreshes tokens and injects Authorization: Bearer before any server connection — works for CLI, act(), and createActSession()
    • New act oauth login|logout|status subcommand
    • act config wizard redesigned for HTTP transports: url → headers → OAuth 2.1 PKCE? → daemon (mutually exclusive with OAuth)
    • Help text and examples use GitHub Copilot MCP (https://api.githubcopilot.com/mcp/) as reference

Usage

# Add to config (or set ONE_ACT_MCP_SERVERS)
# {"github":{"transportType":"streamable-http","url":"https://api.githubcopilot.com/mcp/","auth":"oauth"}}

# Authenticate once
act oauth login github

# Use normally — token is injected automatically
act --manual
act github_get_me '{}'

# Check / revoke
act oauth status
act oauth logout github

Test plan

  • act oauth login <server> opens browser and persists token
  • act oauth status shows token state and expiry
  • act oauth logout <server> clears stored state
  • Token auto-refreshes when within 60 s of expiry
  • Headless: when browser can't open, stderr shows URL + stdin paste prompt
  • act config wizard for streamable-http asks OAuth before daemon
  • OAuth and daemon options are mutually exclusive in the wizard
  • Programmatic act() / createActSession() inject token from stored state
  • auth field is stripped before reaching the MCP transport

🤖 Generated with Claude Code

- New `src/oauth.ts`: full PKCE login flow with local callback server,
  cross-platform browser opener, headless stdin fallback, auto token
  refresh via discoverOAuthServerInfo + refreshAuthorization, and
  disk-backed state in ~/.config/one/oauth-state.json
- `daemon.ts`: add `auth?: "oauth"` to OneActMcpServerConfig; strip it
  in normalizeMcpServersForRuntime so it never reaches the transport layer
- `act.ts`:
  - `injectOAuthHeaders()` fetches/refreshes tokens and injects
    Authorization: Bearer before any server connection (CLI + programmatic
    act() and createActSession())
  - `act oauth login|logout|status` subcommand
  - `act config` wizard: HTTP transport now asks url → headers → OAuth? →
    daemon (only when OAuth is not selected; they are mutually exclusive)
  - Help text and examples updated to use GitHub Copilot MCP as reference

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Adds OAuth 2.1 PKCE authentication support to the one-act tool so HTTP-based MCP servers can be configured with auth:"oauth", authenticated interactively, and then used with automatic Authorization: Bearer injection (including refresh).

Changes:

  • Added a new disk-backed OAuth PKCE implementation with local callback server and token refresh (packages/one-act/src/oauth.ts).
  • Extended MCP server config with auth?: "oauth" and ensured it is stripped before runtime transport config (packages/one-act/src/daemon.ts).
  • Added act oauth login|logout|status, updated act config wizard, and injected OAuth headers for on-demand servers (packages/one-act/src/act.ts).

Reviewed changes

Copilot reviewed 3 out of 3 changed files in this pull request and generated 5 comments.

File Description
packages/one-act/src/oauth.ts Implements OAuth PKCE flow, token persistence, refresh, and local callback handling.
packages/one-act/src/daemon.ts Adds auth config field and strips it out before passing configs to the runtime transport layer.
packages/one-act/src/act.ts Adds CLI subcommands + config wizard steps and injects OAuth bearer tokens into request headers.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +53 to +56
function writeOAuthStateFile(file: OAuthStateFile) {
mkdirSync(dirname(OAUTH_STATE_PATH), { recursive: true });
writeFileSync(OAUTH_STATE_PATH, `${JSON.stringify(file, null, 2)}\n`, "utf-8");
}
Comment on lines +252 to +261
function openBrowser(url: string): boolean {
const cmd =
process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open";
try {
const child = spawn(cmd, [url], { detached: true, stdio: "ignore" });
child.unref();
return true;
} catch {
return false;
}
Comment on lines +326 to +335
// result === "REDIRECT": race callback server vs. manual paste (headless fallback).
// If stdin closes (non-interactive), gracefully fall back to callback-only.
const stdinPrompt = provider.browserOpened
? `If the browser didn't redirect automatically, paste the full redirect URL here and press Enter:\n> `
: `Paste the full redirect URL (or just the code=… value) after authorizing, then press Enter:\n> `;

const stdinRace = waitForCodeFromStdin(stdinPrompt).catch(() => new Promise<string>(() => {}));
code = await Promise.race([callbackServer.waitForCode(), stdinRace]);
} finally {
callbackServer.close();
Comment on lines +202 to +208
if (error) {
res.writeHead(400, { "content-type": "text/html; charset=utf-8" });
res.end(
`<html><body><h1>Authorization failed: ${error}</h1><p>You may close this tab.</p></body></html>`,
);
rejectCode?.(new Error(`OAuth error: ${error}`));
return;
Comment on lines +1412 to +1417
}

process.stderr.write(
`Warning: no valid OAuth token for server "${name}". Run: act oauth login ${name}\n`,
);
}
yaonyan and others added 3 commits May 22, 2026 18:12
…leak, warn callback

- Escape HTML in OAuth callback error page (prevent reflected injection)
- Fix Windows browser opener: use `cmd /c start "" <url>` instead of bare `start`
- Suppress async spawn errors via child.on("error", () => {})
- Add AbortSignal to waitForCodeFromStdin; abort controller in runOAuthLogin
  so the readline interface is cleaned up when the callback server wins the race
- Make injectOAuthHeaders warn callback optional; only pass process.stderr from
  the CLI path so programmatic act()/createActSession() stay side-effect-free

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…r known servers

GitHub's OAuth server does not support Dynamic Client Registration. Add a
KNOWN_CLIENT_IDS map (keyed by hostname) with a pre-registered client_id for
api.githubcopilot.com so `act oauth login github` works out of the box without
any extra config.

- ActOAuthProvider: accept optional clientId; return it from clientInformation()
  when no saved state, causing the SDK to skip DCR entirely
- runOAuthLogin: resolve clientId from arg → KNOWN_CLIENT_IDS → DCR; persist
  clientInfo after success so token refresh works later
- ensureOAuthToken: fall back to KNOWN_CLIENT_IDS when clientInfo not saved
- OneActMcpServerConfig: add optional clientId field for custom servers
- normalizeMcpServersForRuntime: strip clientId before passing to MCP runtime

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
GitHub OAuth Apps require client_secret in the PKCE authorization_code
token exchange even when using PKCE — they return incorrect_client_credentials
without it.  Device Flow explicitly supports no-secret public clients and
is the correct flow for CLI tools.

- Add DEVICE_FLOW_HOSTS set (api.githubcopilot.com) to route servers that
  need Device Flow instead of the standard PKCE web callback
- Implement runGitHubDeviceFlow: discovers scopes from Protected Resource
  Metadata, requests device code, polls token endpoint until user authorizes
- Clear actionable error when "Enable Device Flow" is not checked in the
  OAuth App settings
- Remove debug fetch wrapper from runOAuthLogin

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@yaonyan yaonyan merged commit 94f68de into main May 22, 2026
1 check passed
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