-
Notifications
You must be signed in to change notification settings - Fork 790
Expand file tree
/
Copy pathgithub-at.go
More file actions
340 lines (284 loc) · 9.72 KB
/
github-at.go
File metadata and controls
340 lines (284 loc) · 9.72 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
package auth
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"time"
)
const (
// GitHub OAuth URLs
GitHubDeviceCodeURL = "https://github.com/login/device/code" // #nosec:G101
GitHubAccessTokenURL = "https://github.com/login/oauth/access_token" // #nosec:G101
)
// DeviceCodeResponse represents the response from GitHub's device code endpoint
type DeviceCodeResponse struct {
DeviceCode string `json:"device_code"`
UserCode string `json:"user_code"`
VerificationURI string `json:"verification_uri"`
ExpiresIn int `json:"expires_in"`
Interval int `json:"interval"`
}
// AccessTokenResponse represents the response from GitHub's access token endpoint
type AccessTokenResponse struct {
AccessToken string `json:"access_token"`
TokenType string `json:"token_type"`
Scope string `json:"scope"`
Error string `json:"error,omitempty"`
}
// RegistryTokenResponse represents the response from registry's token exchange endpoint
type RegistryTokenResponse struct {
RegistryToken string `json:"registry_token"`
ExpiresAt int64 `json:"expires_at"`
}
// GitHubATProvider implements the Provider interface using GitHub's device flow
type GitHubATProvider struct {
clientID string
registryURL string
providedToken string // Token provided via --token flag or MCP_GITHUB_TOKEN env var
githubToken string // In-memory GitHub token set by Login()
}
// ServerHealthResponse represents the response from the health endpoint
type ServerHealthResponse struct {
Status string `json:"status"`
GitHubClientID string `json:"github_client_id"`
}
// NewGitHubATProvider creates a new GitHub OAuth provider
func NewGitHubATProvider(registryURL, token string) Provider {
// Check for token from flag or environment variable
if token == "" {
token = os.Getenv("MCP_GITHUB_TOKEN")
}
return &GitHubATProvider{
registryURL: registryURL,
providedToken: token,
}
}
// GetToken retrieves the registry JWT token (exchanges GitHub token if needed)
func (g *GitHubATProvider) GetToken(ctx context.Context) (string, error) {
if g.githubToken == "" {
return "", fmt.Errorf("no GitHub token available; run Login() first")
}
// Exchange GitHub token for registry token
registryToken, _, err := g.exchangeTokenForRegistry(ctx, g.githubToken)
// Clear the GitHub token from memory after exchange
g.githubToken = ""
if err != nil {
return "", fmt.Errorf("failed to exchange token: %w", err)
}
return registryToken, nil
}
// Login performs the GitHub device flow authentication
func (g *GitHubATProvider) Login(ctx context.Context) error {
// If a token was provided via --token or MCP_GITHUB_TOKEN, store it in memory and skip device flow
if g.providedToken != "" {
g.githubToken = g.providedToken
return nil
}
// If clientID is not set, try to retrieve it from the server's health endpoint
if g.clientID == "" {
clientID, err := getClientID(ctx, g.registryURL)
if err != nil {
return fmt.Errorf("error getting GitHub Client ID: %w", err)
}
g.clientID = clientID
}
// Device flow login logic using GitHub's device flow
// First, request a device code
deviceCode, userCode, verificationURI, err := g.requestDeviceCode(ctx)
if err != nil {
return fmt.Errorf("error requesting device code: %w", err)
}
// Display instructions to the user
_, _ = fmt.Fprintln(os.Stdout, "\nTo authenticate, please:")
_, _ = fmt.Fprintln(os.Stdout, "1. Go to:", verificationURI)
_, _ = fmt.Fprintln(os.Stdout, "2. Enter code:", userCode)
_, _ = fmt.Fprintln(os.Stdout, "3. Authorize this application")
// Poll for the token
_, _ = fmt.Fprintln(os.Stdout, "Waiting for authorization...")
token, err := g.pollForToken(ctx, deviceCode)
if err != nil {
return fmt.Errorf("error polling for token: %w", err)
}
// Store the token in memory
g.githubToken = token
_, _ = fmt.Fprintln(os.Stdout, "Successfully authenticated!")
return nil
}
// Name returns the name of this auth provider
func (g *GitHubATProvider) Name() string {
return "github"
}
// requestDeviceCode initiates the device authorization flow
func (g *GitHubATProvider) requestDeviceCode(ctx context.Context) (string, string, string, error) {
if g.clientID == "" {
return "", "", "", fmt.Errorf("GitHub Client ID is required for device flow login")
}
payload := map[string]string{
"client_id": g.clientID,
"scope": "read:org read:user",
}
jsonData, err := json.Marshal(payload)
if err != nil {
return "", "", "", err
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, GitHubDeviceCodeURL, bytes.NewBuffer(jsonData))
if err != nil {
return "", "", "", err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
client := &http.Client{}
resp, err := client.Do(req)
if err != nil {
return "", "", "", err
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return "", "", "", err
}
if resp.StatusCode != http.StatusOK {
return "", "", "", fmt.Errorf("request device code failed: %s", body)
}
var deviceCodeResp DeviceCodeResponse
err = json.Unmarshal(body, &deviceCodeResp)
if err != nil {
return "", "", "", err
}
return deviceCodeResp.DeviceCode, deviceCodeResp.UserCode, deviceCodeResp.VerificationURI, nil
}
// pollForToken polls for access token after user completes authorization
func (g *GitHubATProvider) pollForToken(ctx context.Context, deviceCode string) (string, error) {
if g.clientID == "" {
return "", fmt.Errorf("GitHub Client ID is required for device flow login")
}
payload := map[string]string{
"client_id": g.clientID,
"device_code": deviceCode,
"grant_type": "urn:ietf:params:oauth:grant-type:device_code",
}
jsonData, err := json.Marshal(payload)
if err != nil {
return "", err
}
// Default polling interval and expiration time
interval := 5 // seconds
expiresIn := 900 // 15 minutes
deadline := time.Now().Add(time.Duration(expiresIn) * time.Second)
for time.Now().Before(deadline) {
req, err := http.NewRequestWithContext(ctx, http.MethodPost, GitHubAccessTokenURL, bytes.NewBuffer(jsonData))
if err != nil {
return "", err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
client := &http.Client{}
resp, err := client.Do(req)
if err != nil {
return "", err
}
body, err := io.ReadAll(resp.Body)
resp.Body.Close()
if err != nil {
return "", err
}
var tokenResp AccessTokenResponse
err = json.Unmarshal(body, &tokenResp)
if err != nil {
return "", err
}
if tokenResp.Error == "authorization_pending" {
// User hasn't authorized yet, wait and retry
time.Sleep(time.Duration(interval) * time.Second)
continue
}
if tokenResp.Error != "" {
return "", fmt.Errorf("token request failed: %s", tokenResp.Error)
}
if tokenResp.AccessToken != "" {
return tokenResp.AccessToken, nil
}
// If we reach here, something unexpected happened
return "", fmt.Errorf("failed to obtain access token")
}
return "", fmt.Errorf("device code authorization timed out")
}
func getClientID(ctx context.Context, registryURL string) (string, error) {
// This function should retrieve the GitHub Client ID from the registry URL
// For now, we will return a placeholder value
// In a real implementation, this would likely involve querying the registry or configuration
if registryURL == "" {
return "", fmt.Errorf("registry URL is required to get GitHub Client ID")
}
// get the clientID from the server's health endpoint
healthURL := registryURL + "/v0/health"
req, err := http.NewRequestWithContext(ctx, http.MethodGet, healthURL, nil)
if err != nil {
return "", err
}
client := &http.Client{}
resp, err := client.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return "", fmt.Errorf("health endpoint returned status %d: %s", resp.StatusCode, body)
}
var healthResponse ServerHealthResponse
err = json.NewDecoder(resp.Body).Decode(&healthResponse)
if err != nil {
return "", err
}
if healthResponse.GitHubClientID == "" {
return "", fmt.Errorf("GitHub Client ID is not set in the server's health response")
}
githubClientID := healthResponse.GitHubClientID
return githubClientID, nil
}
// exchangeTokenForRegistry exchanges a GitHub token for a registry JWT token
func (g *GitHubATProvider) exchangeTokenForRegistry(ctx context.Context, githubToken string) (string, int64, error) {
if g.registryURL == "" {
return "", 0, fmt.Errorf("registry URL is required for token exchange")
}
// Prepare the request body
payload := map[string]string{
"github_token": githubToken,
}
jsonData, err := json.Marshal(payload)
if err != nil {
return "", 0, fmt.Errorf("failed to marshal request: %w", err)
}
// Make the token exchange request
exchangeURL := g.registryURL + "/v0/auth/github-at"
req, err := http.NewRequestWithContext(ctx, http.MethodPost, exchangeURL, bytes.NewBuffer(jsonData))
if err != nil {
return "", 0, fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
client := &http.Client{}
resp, err := client.Do(req)
if err != nil {
return "", 0, fmt.Errorf("failed to send request: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return "", 0, fmt.Errorf("failed to read response: %w", err)
}
if resp.StatusCode != http.StatusOK {
return "", 0, fmt.Errorf("token exchange failed with status %d: %s", resp.StatusCode, body)
}
var tokenResp RegistryTokenResponse
err = json.Unmarshal(body, &tokenResp)
if err != nil {
return "", 0, fmt.Errorf("failed to unmarshal response: %w", err)
}
return tokenResp.RegistryToken, tokenResp.ExpiresAt, nil
}