Support multiple authentication flows for enterprise SSO compatibility #3607

Closed
opened 2026-02-16 17:40:51 -05:00 by yindo · 2 comments
Owner

Originally created by @huchenlei on GitHub (Dec 16, 2025).

Originally assigned to: @thdxr on GitHub.

Problem

I can successfully sign in to GitHub Copilot in VSCode using enterprise SSO, but cannot sign in to opencode using the device code flow. The current authentication implementation only supports a single authentication method, which fails for certain enterprise configurations.

Current Implementation

opencode currently only implements the device code flow (/login/device/code) via the opencode-github-copilot package, which may not work for:

  • Enterprise SSO configurations
  • Self-hosted GitHub Enterprise Server (GHES) instances
  • Environments where device code flow is blocked or unsupported
  • Web worker contexts (CORS issues)

Proposed Solution

Implement multiple authentication flows with automatic fallback, matching VSCode's production implementation:

1. OAuth Authorization Code Flow with Local Server

  • Opens a local loopback server to handle OAuth redirects
  • Endpoint: /login/oauth/authorize
  • Best for: Desktop environments with enterprise SSO
  • Supports: PKCE for enhanced security

2. OAuth Authorization Code Flow with URI Handler

  • Uses vscode:// URI handler for redirects
  • Endpoint: /login/oauth/authorize
  • Best for: Web and remote environments
  • Supports: Enterprise SSO, PKCE

3. Device Code Flow (current)

  • Keep existing implementation as fallback
  • Endpoint: /login/device/code
  • Best for: Headless/remote environments

4. Personal Access Token (PAT) Flow

  • Manual token entry as last resort
  • Endpoint: /settings/tokens/new
  • Best for: All environments including old GHES versions

Technical Details

VSCode's implementation (in extensions/github-authentication/src/flows.ts) includes:

Flow Selection Logic:

// Intelligently selects flows based on:
- Extension host type (local/remote/web worker)
- GitHub target (GitHub.com/Enterprise Server/Hosted Enterprise)
- Client capabilities and configuration
- User preferences

Security Features:

  • PKCE (Proof Key for Code Exchange) for OAuth flows
  • Secure token storage via system keychain
  • Proper state/nonce validation

Error Handling:

  • Automatic fallback to next available flow
  • Network error recovery
  • User cancellation handling
  • Timeout management (5min for device code)

Benefits

  1. Broader Compatibility: Works with enterprise SSO, GHES, and restricted environments
  2. Better UX: Automatic fallback prevents authentication failures
  3. Production-Grade: Matches VSCode's battle-tested implementation
  4. Security: PKCE support for OAuth flows

Enterprise Use Case

Many organizations use:

  • GitHub Enterprise with SAML SSO
  • Custom OAuth apps with restricted flows
  • Network policies that block device code endpoints
  • Self-hosted GHES instances (device code requires GHES 3.1+)

Without multiple auth flows, these users cannot use opencode even though they can use VSCode's Copilot.

Reference Implementation

VSCode's GitHub authentication extension:

Minimal Implementation

At minimum, adding OAuth authorization code flow would significantly improve enterprise compatibility:

  1. Supports enterprise SSO configurations
  2. Works where device code is blocked
  3. Provides better UX for desktop users

Related Issue

See detailed implementation discussion in the auth repo: https://github.com/sst/opencode-github-copilot/issues/1

Would appreciate consideration for implementing multiple auth flows to improve enterprise user experience!

Originally created by @huchenlei on GitHub (Dec 16, 2025). Originally assigned to: @thdxr on GitHub. ## Problem I can successfully sign in to GitHub Copilot in VSCode using enterprise SSO, but cannot sign in to opencode using the device code flow. The current authentication implementation only supports a single authentication method, which fails for certain enterprise configurations. ## Current Implementation opencode currently only implements the **device code flow** (`/login/device/code`) via the `opencode-github-copilot` package, which may not work for: - Enterprise SSO configurations - Self-hosted GitHub Enterprise Server (GHES) instances - Environments where device code flow is blocked or unsupported - Web worker contexts (CORS issues) ## Proposed Solution Implement multiple authentication flows with automatic fallback, matching VSCode's production implementation: ### 1. **OAuth Authorization Code Flow with Local Server** - Opens a local loopback server to handle OAuth redirects - Endpoint: `/login/oauth/authorize` - Best for: Desktop environments with enterprise SSO - Supports: PKCE for enhanced security ### 2. **OAuth Authorization Code Flow with URI Handler** - Uses vscode:// URI handler for redirects - Endpoint: `/login/oauth/authorize` - Best for: Web and remote environments - Supports: Enterprise SSO, PKCE ### 3. **Device Code Flow** (current) - Keep existing implementation as fallback - Endpoint: `/login/device/code` - Best for: Headless/remote environments ### 4. **Personal Access Token (PAT) Flow** - Manual token entry as last resort - Endpoint: `/settings/tokens/new` - Best for: All environments including old GHES versions ## Technical Details VSCode's implementation (in `extensions/github-authentication/src/flows.ts`) includes: **Flow Selection Logic:** ```typescript // Intelligently selects flows based on: - Extension host type (local/remote/web worker) - GitHub target (GitHub.com/Enterprise Server/Hosted Enterprise) - Client capabilities and configuration - User preferences ``` **Security Features:** - PKCE (Proof Key for Code Exchange) for OAuth flows - Secure token storage via system keychain - Proper state/nonce validation **Error Handling:** - Automatic fallback to next available flow - Network error recovery - User cancellation handling - Timeout management (5min for device code) ## Benefits 1. **Broader Compatibility**: Works with enterprise SSO, GHES, and restricted environments 2. **Better UX**: Automatic fallback prevents authentication failures 3. **Production-Grade**: Matches VSCode's battle-tested implementation 4. **Security**: PKCE support for OAuth flows ## Enterprise Use Case Many organizations use: - GitHub Enterprise with SAML SSO - Custom OAuth apps with restricted flows - Network policies that block device code endpoints - Self-hosted GHES instances (device code requires GHES 3.1+) Without multiple auth flows, these users cannot use opencode even though they can use VSCode's Copilot. ## Reference Implementation VSCode's GitHub authentication extension: - Main flows: [`extensions/github-authentication/src/flows.ts`](https://github.com/microsoft/vscode/blob/main/extensions/github-authentication/src/flows.ts) - Flow selection: `getFlows()` function with environment detection - Four flow classes: `LocalServerFlow`, `UrlHandlerFlow`, `DeviceCodeFlow`, `PatFlow` ## Minimal Implementation At minimum, adding **OAuth authorization code flow** would significantly improve enterprise compatibility: 1. Supports enterprise SSO configurations 2. Works where device code is blocked 3. Provides better UX for desktop users ## Related Issue See detailed implementation discussion in the auth repo: https://github.com/sst/opencode-github-copilot/issues/1 Would appreciate consideration for implementing multiple auth flows to improve enterprise user experience!
yindo closed this issue 2026-02-16 17:40:51 -05:00
Author
Owner

@github-actions[bot] commented on GitHub (Dec 16, 2025):

This issue might be a duplicate of existing issues. Please check:

  • #3936: Github Enterprise authorization - Reports device authorization failures with GitHub Enterprise deployments
  • #4684: Issue with GitHub Login for OpenCode Authentication - GitHub authentication login process doesn't complete
  • #355: enhancement: support ghe.com copilot auth - Request for GitHub Enterprise authentication support

Feel free to ignore if none of these address your specific case.

@github-actions[bot] commented on GitHub (Dec 16, 2025): This issue might be a duplicate of existing issues. Please check: - #3936: Github Enterprise authorization - Reports device authorization failures with GitHub Enterprise deployments - #4684: Issue with GitHub Login for OpenCode Authentication - GitHub authentication login process doesn't complete - #355: enhancement: support ghe.com copilot auth - Request for GitHub Enterprise authentication support Feel free to ignore if none of these address your specific case.
Author
Owner

@huchenlei commented on GitHub (Dec 16, 2025):

Extra note:

I step down to the auth execution and found https://${domain}/login/device/code returns 404. The error is not properly handled which causes the app to do nothing after clicking "Github Copilot" option in connect dialog.

The code in https://github.com/sst/opencode-github-copilot/ seems to be out-dated.

Runtime code ref from ~/.bun/install/cache/opencode-copilot-auth@0.0.9@@@1/index.mjs:

/**
 * @type {import('@opencode-ai/plugin').Plugin}
 */
export async function CopilotAuthPlugin({ client }) {
  const CLIENT_ID = "Iv1.b507a08c87ecfe98";
  const HEADERS = {
    "User-Agent": "GitHubCopilotChat/0.32.4",
    "Editor-Version": "vscode/1.105.1",
    "Editor-Plugin-Version": "copilot-chat/0.32.4",
    "Copilot-Integration-Id": "vscode-chat",
  };
  const RESPONSES_API_ALTERNATE_INPUT_TYPES = [
    "file_search_call",
    "computer_call",
    "computer_call_output",
    "web_search_call",
    "function_call",
    "function_call_output",
    "image_generation_call",
    "code_interpreter_call",
    "local_shell_call",
    "local_shell_call_output",
    "mcp_list_tools",
    "mcp_approval_request",
    "mcp_approval_response",
    "mcp_call",
    "reasoning",
  ];

  function normalizeDomain(url) {
    return url.replace(/^https?:\/\//, "").replace(/\/$/, "");
  }

  function getUrls(domain) {
    return {
      DEVICE_CODE_URL: `https://${domain}/login/device/code`,
      ACCESS_TOKEN_URL: `https://${domain}/login/oauth/access_token`,
      COPILOT_API_KEY_URL: `https://api.${domain}/copilot_internal/v2/token`,
    };
  }

  return {
    auth: {
      provider: "github-copilot",
      loader: async (getAuth, provider) => {
        let info = await getAuth();
        if (!info || info.type !== "oauth") return {};

        if (provider && provider.models) {
          for (const model of Object.values(provider.models)) {
            model.cost = {
              input: 0,
              output: 0,
              cache: {
                read: 0,
                write: 0,
              },
            };
          }
        }

        // Set baseURL based on deployment type
        const enterpriseUrl = info.enterpriseUrl;
        const baseURL = enterpriseUrl
          ? `https://copilot-api.${normalizeDomain(enterpriseUrl)}`
          : "https://api.githubcopilot.com";

        return {
          baseURL,
          apiKey: "",
          async fetch(input, init) {
            const info = await getAuth();
            if (info.type !== "oauth") return {};
            if (!info.access || info.expires < Date.now()) {
              const domain = info.enterpriseUrl
                ? normalizeDomain(info.enterpriseUrl)
                : "github.com";
              const urls = getUrls(domain);

              const response = await fetch(urls.COPILOT_API_KEY_URL, {
                headers: {
                  Accept: "application/json",
                  Authorization: `Bearer ${info.refresh}`,
                  ...HEADERS,
                },
              });

              if (!response.ok) {
                throw new Error(`Token refresh failed: ${response.status}`);
              }

              const tokenData = await response.json();

              const saveProviderID = info.enterpriseUrl
                ? "github-copilot-enterprise"
                : "github-copilot";
              await client.auth.set({
                path: {
                  id: saveProviderID,
                },
                body: {
                  type: "oauth",
                  refresh: info.refresh,
                  access: tokenData.token,
                  expires: tokenData.expires_at * 1000,
                  ...(info.enterpriseUrl && {
                    enterpriseUrl: info.enterpriseUrl,
                  }),
                },
              });
              info.access = tokenData.token;
            }
            let isAgentCall = false;
            let isVisionRequest = false;
            try {
              const body =
                typeof init.body === "string"
                  ? JSON.parse(init.body)
                  : init.body;
              if (body?.messages) {
                isAgentCall = body.messages.some(
                  (msg) => msg.role && ["tool", "assistant"].includes(msg.role),
                );
                isVisionRequest = body.messages.some(
                  (msg) =>
                    Array.isArray(msg.content) &&
                    msg.content.some((part) => part.type === "image_url"),
                );
              }

              if (body?.input) {
                const lastInput = body.input[body.input.length - 1];

                const isAssistant = lastInput?.role === "assistant";
                const hasAgentType = lastInput?.type
                  ? RESPONSES_API_ALTERNATE_INPUT_TYPES.includes(lastInput.type)
                  : false;
                isAgentCall = isAssistant || hasAgentType;

                isVisionRequest =
                  Array.isArray(lastInput?.content) &&
                  lastInput.content.some((part) => part.type === "input_image");
              }
            } catch {}
            const headers = {
              ...init.headers,
              ...HEADERS,
              Authorization: `Bearer ${info.access}`,
              "Openai-Intent": "conversation-edits",
              "X-Initiator": isAgentCall ? "agent" : "user",
            };
            if (isVisionRequest) {
              headers["Copilot-Vision-Request"] = "true";
            }

            delete headers["x-api-key"];
            delete headers["authorization"];

            return fetch(input, {
              ...init,
              headers,
            });
          },
        };
      },
      methods: [
        {
          type: "oauth",
          label: "Login with GitHub Copilot",
          prompts: [
            {
              type: "select",
              key: "deploymentType",
              message: "Select GitHub deployment type",
              options: [
                {
                  label: "GitHub.com",
                  value: "github.com",
                  hint: "Public",
                },
                {
                  label: "GitHub Enterprise",
                  value: "enterprise",
                  hint: "Data residency or self-hosted",
                },
              ],
            },
            {
              type: "text",
              key: "enterpriseUrl",
              message: "Enter your GitHub Enterprise URL or domain",
              placeholder: "company.ghe.com or https://company.ghe.com",
              condition: (inputs) => inputs.deploymentType === "enterprise",
              validate: (value) => {
                if (!value) return "URL or domain is required";
                try {
                  const url = value.includes("://")
                    ? new URL(value)
                    : new URL(`https://${value}`);
                  if (!url.hostname)
                    return "Please enter a valid URL or domain";
                  return undefined;
                } catch {
                  return "Please enter a valid URL (e.g., company.ghe.com or https://company.ghe.com)";
                }
              },
            },
          ],
          async authorize(inputs = {}) {
            const deploymentType = inputs.deploymentType || "github.com";

            let domain = "github.com";
            let actualProvider = "github-copilot";

            if (deploymentType === "enterprise") {
              const enterpriseUrl = inputs.enterpriseUrl;
              domain = normalizeDomain(enterpriseUrl);
              actualProvider = "github-copilot-enterprise";
            }

            const urls = getUrls(domain);

            const deviceResponse = await fetch(urls.DEVICE_CODE_URL, {
              method: "POST",
              headers: {
                Accept: "application/json",
                "Content-Type": "application/json",
                "User-Agent": "GitHubCopilotChat/0.35.0",
              },
              body: JSON.stringify({
                client_id: CLIENT_ID,
                scope: "read:user",
              }),
            });

            if (!deviceResponse.ok) {
              throw new Error("Failed to initiate device authorization");
            }

            const deviceData = await deviceResponse.json();

            return {
              url: deviceData.verification_uri,
              instructions: `Enter code: ${deviceData.user_code}`,
              method: "auto",
              callback: async () => {
                while (true) {
                  const response = await fetch(urls.ACCESS_TOKEN_URL, {
                    method: "POST",
                    headers: {
                      Accept: "application/json",
                      "Content-Type": "application/json",
                      "User-Agent": "GitHubCopilotChat/0.35.0",
                    },
                    body: JSON.stringify({
                      client_id: CLIENT_ID,
                      device_code: deviceData.device_code,
                      grant_type:
                        "urn:ietf:params:oauth:grant-type:device_code",
                    }),
                  });

                  if (!response.ok) return { type: "failed" };

                  const data = await response.json();

                  if (data.access_token) {
                    const result = {
                      type: "success",
                      refresh: data.access_token,
                      access: "",
                      expires: 0,
                    };

                    if (actualProvider === "github-copilot-enterprise") {
                      result.provider = "github-copilot-enterprise";
                      result.enterpriseUrl = domain;
                    }

                    return result;
                  }

                  if (data.error === "authorization_pending") {
                    await new Promise((resolve) =>
                      setTimeout(resolve, deviceData.interval * 1000),
                    );
                    continue;
                  }

                  if (data.error) return { type: "failed" };

                  await new Promise((resolve) =>
                    setTimeout(resolve, deviceData.interval * 1000),
                  );
                  continue;
                }
              },
            };
          },
        },
      ],
    },
  };
}
@huchenlei commented on GitHub (Dec 16, 2025): Extra note: I step down to the auth execution and found `https://${domain}/login/device/code` returns `404`. The error is not properly handled which causes the app to do nothing after clicking "Github Copilot" option in connect dialog. The code in https://github.com/sst/opencode-github-copilot/ seems to be out-dated. Runtime code ref from `~/.bun/install/cache/opencode-copilot-auth@0.0.9@@@1/index.mjs`: ```js /** * @type {import('@opencode-ai/plugin').Plugin} */ export async function CopilotAuthPlugin({ client }) { const CLIENT_ID = "Iv1.b507a08c87ecfe98"; const HEADERS = { "User-Agent": "GitHubCopilotChat/0.32.4", "Editor-Version": "vscode/1.105.1", "Editor-Plugin-Version": "copilot-chat/0.32.4", "Copilot-Integration-Id": "vscode-chat", }; const RESPONSES_API_ALTERNATE_INPUT_TYPES = [ "file_search_call", "computer_call", "computer_call_output", "web_search_call", "function_call", "function_call_output", "image_generation_call", "code_interpreter_call", "local_shell_call", "local_shell_call_output", "mcp_list_tools", "mcp_approval_request", "mcp_approval_response", "mcp_call", "reasoning", ]; function normalizeDomain(url) { return url.replace(/^https?:\/\//, "").replace(/\/$/, ""); } function getUrls(domain) { return { DEVICE_CODE_URL: `https://${domain}/login/device/code`, ACCESS_TOKEN_URL: `https://${domain}/login/oauth/access_token`, COPILOT_API_KEY_URL: `https://api.${domain}/copilot_internal/v2/token`, }; } return { auth: { provider: "github-copilot", loader: async (getAuth, provider) => { let info = await getAuth(); if (!info || info.type !== "oauth") return {}; if (provider && provider.models) { for (const model of Object.values(provider.models)) { model.cost = { input: 0, output: 0, cache: { read: 0, write: 0, }, }; } } // Set baseURL based on deployment type const enterpriseUrl = info.enterpriseUrl; const baseURL = enterpriseUrl ? `https://copilot-api.${normalizeDomain(enterpriseUrl)}` : "https://api.githubcopilot.com"; return { baseURL, apiKey: "", async fetch(input, init) { const info = await getAuth(); if (info.type !== "oauth") return {}; if (!info.access || info.expires < Date.now()) { const domain = info.enterpriseUrl ? normalizeDomain(info.enterpriseUrl) : "github.com"; const urls = getUrls(domain); const response = await fetch(urls.COPILOT_API_KEY_URL, { headers: { Accept: "application/json", Authorization: `Bearer ${info.refresh}`, ...HEADERS, }, }); if (!response.ok) { throw new Error(`Token refresh failed: ${response.status}`); } const tokenData = await response.json(); const saveProviderID = info.enterpriseUrl ? "github-copilot-enterprise" : "github-copilot"; await client.auth.set({ path: { id: saveProviderID, }, body: { type: "oauth", refresh: info.refresh, access: tokenData.token, expires: tokenData.expires_at * 1000, ...(info.enterpriseUrl && { enterpriseUrl: info.enterpriseUrl, }), }, }); info.access = tokenData.token; } let isAgentCall = false; let isVisionRequest = false; try { const body = typeof init.body === "string" ? JSON.parse(init.body) : init.body; if (body?.messages) { isAgentCall = body.messages.some( (msg) => msg.role && ["tool", "assistant"].includes(msg.role), ); isVisionRequest = body.messages.some( (msg) => Array.isArray(msg.content) && msg.content.some((part) => part.type === "image_url"), ); } if (body?.input) { const lastInput = body.input[body.input.length - 1]; const isAssistant = lastInput?.role === "assistant"; const hasAgentType = lastInput?.type ? RESPONSES_API_ALTERNATE_INPUT_TYPES.includes(lastInput.type) : false; isAgentCall = isAssistant || hasAgentType; isVisionRequest = Array.isArray(lastInput?.content) && lastInput.content.some((part) => part.type === "input_image"); } } catch {} const headers = { ...init.headers, ...HEADERS, Authorization: `Bearer ${info.access}`, "Openai-Intent": "conversation-edits", "X-Initiator": isAgentCall ? "agent" : "user", }; if (isVisionRequest) { headers["Copilot-Vision-Request"] = "true"; } delete headers["x-api-key"]; delete headers["authorization"]; return fetch(input, { ...init, headers, }); }, }; }, methods: [ { type: "oauth", label: "Login with GitHub Copilot", prompts: [ { type: "select", key: "deploymentType", message: "Select GitHub deployment type", options: [ { label: "GitHub.com", value: "github.com", hint: "Public", }, { label: "GitHub Enterprise", value: "enterprise", hint: "Data residency or self-hosted", }, ], }, { type: "text", key: "enterpriseUrl", message: "Enter your GitHub Enterprise URL or domain", placeholder: "company.ghe.com or https://company.ghe.com", condition: (inputs) => inputs.deploymentType === "enterprise", validate: (value) => { if (!value) return "URL or domain is required"; try { const url = value.includes("://") ? new URL(value) : new URL(`https://${value}`); if (!url.hostname) return "Please enter a valid URL or domain"; return undefined; } catch { return "Please enter a valid URL (e.g., company.ghe.com or https://company.ghe.com)"; } }, }, ], async authorize(inputs = {}) { const deploymentType = inputs.deploymentType || "github.com"; let domain = "github.com"; let actualProvider = "github-copilot"; if (deploymentType === "enterprise") { const enterpriseUrl = inputs.enterpriseUrl; domain = normalizeDomain(enterpriseUrl); actualProvider = "github-copilot-enterprise"; } const urls = getUrls(domain); const deviceResponse = await fetch(urls.DEVICE_CODE_URL, { method: "POST", headers: { Accept: "application/json", "Content-Type": "application/json", "User-Agent": "GitHubCopilotChat/0.35.0", }, body: JSON.stringify({ client_id: CLIENT_ID, scope: "read:user", }), }); if (!deviceResponse.ok) { throw new Error("Failed to initiate device authorization"); } const deviceData = await deviceResponse.json(); return { url: deviceData.verification_uri, instructions: `Enter code: ${deviceData.user_code}`, method: "auto", callback: async () => { while (true) { const response = await fetch(urls.ACCESS_TOKEN_URL, { method: "POST", headers: { Accept: "application/json", "Content-Type": "application/json", "User-Agent": "GitHubCopilotChat/0.35.0", }, body: JSON.stringify({ client_id: CLIENT_ID, device_code: deviceData.device_code, grant_type: "urn:ietf:params:oauth:grant-type:device_code", }), }); if (!response.ok) return { type: "failed" }; const data = await response.json(); if (data.access_token) { const result = { type: "success", refresh: data.access_token, access: "", expires: 0, }; if (actualProvider === "github-copilot-enterprise") { result.provider = "github-copilot-enterprise"; result.enterpriseUrl = domain; } return result; } if (data.error === "authorization_pending") { await new Promise((resolve) => setTimeout(resolve, deviceData.interval * 1000), ); continue; } if (data.error) return { type: "failed" }; await new Promise((resolve) => setTimeout(resolve, deviceData.interval * 1000), ); continue; } }, }; }, }, ], }, }; } ```
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: anomalyco/opencode#3607