mirror of
https://github.com/patriceckhart/zot.git
synced 2026-06-27 05:46:34 +02:00
Add GitHub Copilot subscription login and broaden API-key login to all catalog providers. Persist credentials for additional API-key providers, include them in model filtering and logout, and fix clearing those stored credentials. Improve provider/model/slash pickers with pagination and clearer credential-state labels.
132 lines
4.6 KiB
Go
132 lines
4.6 KiB
Go
package auth
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/url"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
const githubCopilotClientID = "Iv1.b507a08c87ecfe98"
|
|
|
|
// GitHubCopilotDeviceAuthorization is GitHub's OAuth 2 device-code
|
|
// response used for Copilot subscription login.
|
|
type GitHubCopilotDeviceAuthorization 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"`
|
|
}
|
|
|
|
// RequestGitHubCopilotDeviceAuthorization starts GitHub Copilot's
|
|
// device-code login. The resulting GitHub access token is later traded
|
|
// for short-lived Copilot inference tokens by the provider client.
|
|
func RequestGitHubCopilotDeviceAuthorization(ctx context.Context) (GitHubCopilotDeviceAuthorization, error) {
|
|
form := url.Values{}
|
|
form.Set("client_id", githubCopilotClientID)
|
|
form.Set("scope", "read:user")
|
|
req, err := http.NewRequestWithContext(ctx, "POST", "https://github.com/login/device/code", bytes.NewBufferString(form.Encode()))
|
|
if err != nil {
|
|
return GitHubCopilotDeviceAuthorization{}, err
|
|
}
|
|
req.Header.Set("content-type", "application/x-www-form-urlencoded")
|
|
req.Header.Set("accept", "application/json")
|
|
req.Header.Set("user-agent", "GitHubCopilotChat/0.35.0")
|
|
|
|
resp, err := (&http.Client{Timeout: 30 * time.Second}).Do(req)
|
|
if err != nil {
|
|
return GitHubCopilotDeviceAuthorization{}, fmt.Errorf("github copilot device authorization: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
body, _ := io.ReadAll(resp.Body)
|
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
|
return GitHubCopilotDeviceAuthorization{}, fmt.Errorf("github copilot device authorization http %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
|
|
}
|
|
var out GitHubCopilotDeviceAuthorization
|
|
if err := json.Unmarshal(body, &out); err != nil {
|
|
return out, fmt.Errorf("parse github copilot device authorization: %w", err)
|
|
}
|
|
if out.Interval <= 0 {
|
|
out.Interval = 5
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// PollGitHubCopilotDeviceToken polls until GitHub's browser/device-code
|
|
// login completes and returns the GitHub access token.
|
|
func PollGitHubCopilotDeviceToken(ctx context.Context, auth GitHubCopilotDeviceAuthorization) (*OAuthToken, error) {
|
|
interval := time.Duration(auth.Interval) * time.Second
|
|
if interval <= 0 {
|
|
interval = 5 * time.Second
|
|
}
|
|
deadline := time.Now().Add(time.Duration(auth.ExpiresIn) * time.Second)
|
|
for {
|
|
if auth.ExpiresIn > 0 && time.Now().After(deadline) {
|
|
return nil, fmt.Errorf("github copilot device login expired")
|
|
}
|
|
tok, retry, err := pollGitHubCopilotDeviceTokenOnce(ctx, auth.DeviceCode, interval)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if tok != nil {
|
|
return tok, nil
|
|
}
|
|
select {
|
|
case <-ctx.Done():
|
|
return nil, ctx.Err()
|
|
case <-time.After(retry):
|
|
}
|
|
}
|
|
}
|
|
|
|
func pollGitHubCopilotDeviceTokenOnce(ctx context.Context, deviceCode string, interval time.Duration) (*OAuthToken, time.Duration, error) {
|
|
form := url.Values{}
|
|
form.Set("client_id", githubCopilotClientID)
|
|
form.Set("device_code", deviceCode)
|
|
form.Set("grant_type", "urn:ietf:params:oauth:grant-type:device_code")
|
|
req, err := http.NewRequestWithContext(ctx, "POST", "https://github.com/login/oauth/access_token", bytes.NewBufferString(form.Encode()))
|
|
if err != nil {
|
|
return nil, 0, err
|
|
}
|
|
req.Header.Set("content-type", "application/x-www-form-urlencoded")
|
|
req.Header.Set("accept", "application/json")
|
|
req.Header.Set("user-agent", "GitHubCopilotChat/0.35.0")
|
|
resp, err := (&http.Client{Timeout: 30 * time.Second}).Do(req)
|
|
if err != nil {
|
|
return nil, 0, fmt.Errorf("github copilot token poll: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
body, _ := io.ReadAll(resp.Body)
|
|
var raw struct {
|
|
AccessToken string `json:"access_token"`
|
|
TokenType string `json:"token_type"`
|
|
Scope string `json:"scope"`
|
|
Error string `json:"error"`
|
|
ErrorDescription string `json:"error_description"`
|
|
}
|
|
_ = json.Unmarshal(body, &raw)
|
|
if resp.StatusCode == http.StatusOK && raw.AccessToken != "" {
|
|
return &OAuthToken{
|
|
AccessToken: raw.AccessToken,
|
|
TokenType: raw.TokenType,
|
|
Scope: raw.Scope,
|
|
ClientID: githubCopilotClientID,
|
|
}, 0, nil
|
|
}
|
|
if raw.Error == "authorization_pending" || resp.StatusCode == http.StatusBadRequest {
|
|
return nil, interval, nil
|
|
}
|
|
if raw.Error == "slow_down" {
|
|
return nil, interval + 5*time.Second, nil
|
|
}
|
|
if raw.Error != "" {
|
|
return nil, 0, fmt.Errorf("github copilot token poll: %s: %s", raw.Error, raw.ErrorDescription)
|
|
}
|
|
return nil, 0, fmt.Errorf("github copilot token poll http %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
|
|
}
|