Skip to content

Commit bb7ac1c

Browse files
ericdalloeca-agent
andcommitted
Add GitHub Enterprise support for Copilot authentication
Replace hardcoded github.com OAuth URLs and client ID in the Copilot provider with config-driven values. OAuth device-flow endpoints are now derived from the provider's `auth.url` setting, defaulting to github.com when unset. A `auth.clientId` override is also supported for enterprise instances that use a different OAuth application. The `auth` object is added to the generic provider config schema so it can be reused by other providers in the future. Closes #402 🤖 Generated with [eca](https://eca.dev) Co-Authored-By: eca <git@eca.dev>
1 parent 0245c48 commit bb7ac1c

File tree

4 files changed

+104
-53
lines changed

4 files changed

+104
-53
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
## Unreleased
44

55
- Fix `/resume` broken for OpenAI chats: handle nil reasoning text during replay, preserve prompt-id after chat replacement, and clear UI before replaying messages. #400
6+
- Add GitHub Enterprise support for Copilot authentication via `auth.url` and `auth.clientId` provider config. #402
67

78
## 0.124.2
89

docs/config.json

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -498,6 +498,26 @@
498498
"description": "Whether this provider requires authentication.",
499499
"markdownDescription": "Whether this provider requires authentication."
500500
},
501+
"auth": {
502+
"type": "object",
503+
"description": "Authentication endpoint overrides for providers that use OAuth device flow. Used when the provider's identity server differs from the default (e.g. enterprise or self-hosted instances).",
504+
"markdownDescription": "Authentication endpoint overrides for providers that use OAuth device flow. Used when the provider's identity server differs from the default (e.g. enterprise or self-hosted instances).",
505+
"additionalProperties": false,
506+
"properties": {
507+
"url": {
508+
"type": "string",
509+
"description": "Base URL of the identity provider. OAuth device-flow endpoints are derived from this URL.",
510+
"markdownDescription": "Base URL of the identity provider. OAuth device-flow endpoints are derived from this URL.",
511+
"format": "uri",
512+
"examples": ["https://github.mycompany.com"]
513+
},
514+
"clientId": {
515+
"type": "string",
516+
"description": "OAuth client ID to use for the device-flow. Defaults to the provider's public client ID when omitted.",
517+
"markdownDescription": "OAuth client ID to use for the device-flow. Defaults to the provider's public client ID when omitted."
518+
}
519+
}
520+
},
501521
"completionUrlRelativePath": {
502522
"type": "string",
503523
"description": "Custom relative URL path for completion requests.",

src/eca/llm_providers/copilot.clj

Lines changed: 53 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -9,37 +9,49 @@
99
[eca.shared :refer [multi-str]]
1010
[hato.client :as http]))
1111

12-
(def ^:private client-id "Iv1.b507a08c87ecfe98")
12+
(set! *warn-on-reflection* true)
13+
14+
(def ^:private default-client-id "Iv1.b507a08c87ecfe98")
15+
16+
(defn ^:private github-base-url [provider-settings]
17+
(or (get-in provider-settings [:auth :url])
18+
"https://github.com"))
19+
20+
(defn ^:private github-api-base-url [provider-settings]
21+
(let [base (github-base-url provider-settings)]
22+
(if (= base "https://github.com")
23+
"https://api.github.com"
24+
(str base "/api/v3"))))
25+
26+
(defn ^:private copilot-client-id [provider-settings]
27+
(or (get-in provider-settings [:auth :clientId])
28+
default-client-id))
1329

1430
(defn ^:private auth-headers []
1531
{"Content-Type" "application/json"
1632
"Accept" "application/json"
1733
"editor-plugin-version" "eca/*"
1834
"editor-version" (str "eca/" (config/eca-version))})
1935

20-
(def ^:private oauth-login-device-url
21-
"https://github.com/login/device/code")
22-
23-
(defn ^:private oauth-url []
24-
(let [{:keys [body]} (http/post
25-
oauth-login-device-url
36+
(defn ^:private oauth-url [provider-settings]
37+
(let [device-url (str (github-base-url provider-settings) "/login/device/code")
38+
{:keys [body]} (http/post
39+
device-url
2640
{:headers (auth-headers)
27-
:body (json/generate-string {:client_id client-id
41+
:body (json/generate-string {:client_id (copilot-client-id provider-settings)
2842
:scope "read:user"})
2943
:http-client (client/merge-with-global-http-client {})
3044
:as :json})]
3145
{:user-code (:user_code body)
3246
:device-code (:device_code body)
3347
:url (:verification_uri body)}))
3448

35-
(def ^:private oauth-login-access-token-url
36-
"https://github.com/login/oauth/access_token")
37-
38-
(defn ^:private oauth-access-token [device-code]
39-
(let [{:keys [status body]} (http/post
40-
oauth-login-access-token-url
49+
(defn ^:private oauth-access-token [provider-settings device-code]
50+
(let [access-token-url (str (github-base-url provider-settings) "/login/oauth/access_token")
51+
{:keys [status body]} (http/post
52+
access-token-url
4153
{:headers (auth-headers)
42-
:body (json/generate-string {:client_id client-id
54+
:body (json/generate-string {:client_id (copilot-client-id provider-settings)
4355
:device_code device-code
4456
:grant_type "urn:ietf:params:oauth:grant-type:device_code"})
4557
:throw-exceptions? false
@@ -51,12 +63,10 @@
5163
{:status status
5264
:body body})))))
5365

54-
(def ^:private oauth-copilot-token-url
55-
"https://api.github.com/copilot_internal/v2/token")
56-
57-
(defn ^:private oauth-renew-token [access-token]
58-
(let [{:keys [status body]} (http/get
59-
oauth-copilot-token-url
66+
(defn ^:private oauth-renew-token [provider-settings access-token]
67+
(let [token-url (str (github-api-base-url provider-settings) "/copilot_internal/v2/token")
68+
{:keys [status body]} (http/get
69+
token-url
6070
{:headers (merge (auth-headers)
6171
{"authorization" (str "token " access-token)})
6272
:throw-exceptions? false
@@ -71,8 +81,9 @@
7181

7282
;; --- Settings-based login (providers/login flow) ---
7383

74-
(defmethod f.providers/start-login! ["github-copilot" "device"] [_ _ db* _config messenger metrics]
75-
(let [{:keys [user-code device-code url]} (oauth-url)]
84+
(defmethod f.providers/start-login! ["github-copilot" "device"] [_ _ db* config messenger metrics]
85+
(let [provider-settings (get-in config [:providers "github-copilot"])
86+
{:keys [user-code device-code url]} (oauth-url provider-settings)]
7687
(swap! db* assoc-in [:auth "github-copilot"] {:step :login/waiting-user-confirmation
7788
:device-code device-code})
7889
(future
@@ -82,8 +93,8 @@
8293
(= :login/waiting-user-confirmation
8394
(get-in @db* [:auth "github-copilot" :step])))
8495
(let [result (try
85-
(let [access-token (oauth-access-token device-code)
86-
{:keys [api-key expires-at]} (oauth-renew-token access-token)]
96+
(let [access-token (oauth-access-token provider-settings device-code)
97+
{:keys [api-key expires-at]} (oauth-renew-token provider-settings access-token)]
8798
(swap! db* update-in [:auth "github-copilot"] merge
8899
{:step :login/done
89100
:access-token access-token
@@ -99,35 +110,40 @@
99110
{:action "device-code"
100111
:url url
101112
:code user-code
102-
:message "Enter this code at the URL above. Make sure Copilot is enabled at https://github.com/settings/copilot/features"}))
113+
:message (format "Enter this code at the URL above. Make sure Copilot is enabled at %s/settings/copilot/features"
114+
(github-base-url provider-settings))}))
103115

104116
;; --- Chat-based login (legacy /login command) ---
105117

106-
(defmethod f.login/login-step ["github-copilot" :login/start] [{:keys [db* chat-id provider send-msg!]}]
107-
(let [{:keys [user-code device-code url]} (oauth-url)]
118+
(defmethod f.login/login-step ["github-copilot" :login/start] [{:keys [db* chat-id provider config send-msg!]}]
119+
(let [provider-settings (get-in config [:providers provider])
120+
{:keys [user-code device-code url]} (oauth-url provider-settings)
121+
github-url (github-base-url provider-settings)]
108122
(swap! db* assoc-in [:chats chat-id :login-provider] provider)
109123
(swap! db* assoc-in [:auth provider] {:step :login/waiting-user-confirmation
110124
:device-code device-code})
111125
(send-msg! (multi-str
112-
"First, make sure you have Copilot enabled in you Github account: https://github.com/settings/copilot/features"
126+
(format "First, make sure you have Copilot enabled in your Github account: %s/settings/copilot/features" github-url)
113127
(format "Then, open your browser at:\n\n%s\n\nAuthenticate using the code: `%s`\nThen type anything in the chat and send it to continue the authentication."
114128
url
115129
user-code)))))
116130

117-
(defmethod f.login/login-step ["github-copilot" :login/waiting-user-confirmation] [{:keys [db* provider send-msg!] :as ctx}]
118-
(let [access-token (oauth-access-token (get-in @db* [:auth provider :device-code]))
119-
{:keys [api-key expires-at]} (oauth-renew-token access-token)]
131+
(defmethod f.login/login-step ["github-copilot" :login/waiting-user-confirmation] [{:keys [db* provider config send-msg!] :as ctx}]
132+
(let [provider-settings (get-in config [:providers provider])
133+
access-token (oauth-access-token provider-settings (get-in @db* [:auth provider :device-code]))
134+
{:keys [api-key expires-at]} (oauth-renew-token provider-settings access-token)]
120135
(swap! db* update-in [:auth provider] merge {:step :login/done
121136
:access-token access-token
122137
:api-key api-key
123138
:expires-at expires-at})
124-
125139
(f.login/login-done! ctx)
126-
(send-msg! "\nMake sure to enable the model you want use at: https://github.com/settings/copilot/features")))
140+
(send-msg! (format "\nMake sure to enable the model you want to use at: %s/settings/copilot/features"
141+
(github-base-url provider-settings)))))
127142

128-
(defmethod f.login/login-step ["github-copilot" :login/renew-token] [{:keys [db* provider] :as ctx}]
129-
(let [access-token (get-in @db* [:auth provider :access-token])
130-
{:keys [api-key expires-at]} (oauth-renew-token access-token)]
143+
(defmethod f.login/login-step ["github-copilot" :login/renew-token] [{:keys [db* provider config] :as ctx}]
144+
(let [provider-settings (get-in config [:providers provider])
145+
access-token (get-in @db* [:auth provider :access-token])
146+
{:keys [api-key expires-at]} (oauth-renew-token provider-settings access-token)]
131147
(swap! db* update-in [:auth provider] merge {:api-key api-key
132148
:expires-at expires-at})
133149
(f.login/login-done! ctx :silent? true :skip-models-sync? true)))

test/eca/llm_providers/copilot_test.clj

Lines changed: 30 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,29 @@
44
[eca.client-test-helpers :refer [with-client-proxied]]
55
[eca.llm-providers.copilot :as llm-providers.copilot]))
66

7+
(def ^:private test-provider-settings
8+
"Provider settings using plain HTTP so requests route through the test proxy."
9+
{:auth {:url "http://localhost:99"}})
10+
11+
(deftest github-url-derivation-test
12+
(testing "defaults to github.com when no auth config"
13+
(is (= "https://github.com" (#'llm-providers.copilot/github-base-url {})))
14+
(is (= "https://api.github.com" (#'llm-providers.copilot/github-api-base-url {})))
15+
(is (= "Iv1.b507a08c87ecfe98" (#'llm-providers.copilot/copilot-client-id {}))))
16+
17+
(testing "uses custom GitHub Enterprise URL"
18+
(let [settings {:auth {:url "https://ghe.example.com"}}]
19+
(is (= "https://ghe.example.com" (#'llm-providers.copilot/github-base-url settings)))
20+
(is (= "https://ghe.example.com/api/v3" (#'llm-providers.copilot/github-api-base-url settings)))))
21+
22+
(testing "uses custom client ID"
23+
(let [settings {:auth {:clientId "custom-id"}}]
24+
(is (= "custom-id" (#'llm-providers.copilot/copilot-client-id settings)))))
25+
26+
(testing "defaults client ID when only URL is overridden"
27+
(let [settings {:auth {:url "https://ghe.example.com"}}]
28+
(is (= "Iv1.b507a08c87ecfe98" (#'llm-providers.copilot/copilot-client-id settings))))))
29+
730
(deftest oauth-url-test
831
(testing "constructs GitHub device OAuth request and parses key response fields"
932
(let [req* (atom nil)]
@@ -16,17 +39,14 @@
1639
:device_code "DEVICE-CODE"
1740
:verification_uri "https://github.com/login/device"}})
1841

19-
(let [result
20-
(with-redefs [llm-providers.copilot/oauth-login-device-url
21-
"http://localhost:99/login/device/code"]
22-
(#'llm-providers.copilot/oauth-url))]
42+
(let [result (#'llm-providers.copilot/oauth-url test-provider-settings)]
2343

2444
;; request validation
2545
(is (= {:method "POST"
2646
:uri "/login/device/code"}
2747
(select-keys @req* [:method :uri])))
2848

29-
(is (= {:client_id @#'llm-providers.copilot/client-id
49+
(is (= {:client_id "Iv1.b507a08c87ecfe98"
3050
:scope "read:user"}
3151
(:body @req*))
3252
"Outgoing payload should match device-code request")
@@ -48,17 +68,14 @@
4868
:body {:access_token "gh-access-token"}})
4969

5070
(let [device-code "device-code-123"
51-
result
52-
(with-redefs [llm-providers.copilot/oauth-login-access-token-url
53-
"http://localhost:99/login/oauth/access_token"]
54-
(#'llm-providers.copilot/oauth-access-token device-code))]
71+
result (#'llm-providers.copilot/oauth-access-token test-provider-settings device-code)]
5572

5673
;; request validation
5774
(is (= {:method "POST"
5875
:uri "/login/oauth/access_token"}
5976
(select-keys @req* [:method :uri])))
6077

61-
(is (= {:client_id @#'llm-providers.copilot/client-id
78+
(is (= {:client_id "Iv1.b507a08c87ecfe98"
6279
:device_code device-code
6380
:grant_type "urn:ietf:params:oauth:grant-type:device_code"}
6481
(:body @req*))
@@ -79,14 +96,11 @@
7996
:expires_at 9999999999}})
8097

8198
(let [access-token "gh-access-123"
82-
result
83-
(with-redefs [llm-providers.copilot/oauth-copilot-token-url
84-
"http://localhost:99/copilot_internal/v2/token"]
85-
(#'llm-providers.copilot/oauth-renew-token access-token))]
99+
result (#'llm-providers.copilot/oauth-renew-token test-provider-settings access-token)]
86100

87-
;; request validation
101+
;; request validation — uses /api/v3 prefix since test settings use a custom auth URL
88102
(is (= {:method "GET"
89-
:uri "/copilot_internal/v2/token"}
103+
:uri "/api/v3/copilot_internal/v2/token"}
90104
(select-keys @req* [:method :uri])))
91105

92106
(is (= {"authorization" (str "token " access-token)

0 commit comments

Comments
 (0)