304 lines
11 KiB
Go
304 lines
11 KiB
Go
package qoder
|
|
|
|
// PLUGIN-SERVED (see AGENTS.md): Qoder ("qoder") and Qoder CN ("qoder-cn")
|
|
// are deprecated built-in subscriptions served by their plugin,
|
|
// @magpie-community/opencode-qoder-auth, each once moved onto it
|
|
// (provider.Moved; the default for a new sign-in). A moved one's
|
|
// sign-ins, models, requests and usage are all the plugin's, never this
|
|
// code's (only the move, in migrate*.go, still reads its accounts). A fix here alone doesn't reach those users; fix the plugin
|
|
// (github.com/magpie-community/plugins, packages/qoder) and raise the
|
|
// movers' min in internal/provider/migrate_qoder.go.
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"crypto/rand"
|
|
"crypto/sha256"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/url"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// DeviceToken is the dt- device token a finished device flow returns.
|
|
type DeviceToken struct {
|
|
Token string `json:"token"`
|
|
DeviceToken string `json:"device_token,omitempty"`
|
|
RefreshToken string `json:"refresh_token"`
|
|
UserID string `json:"user_id"`
|
|
UserName string `json:"user_name,omitempty"`
|
|
// ExpiresIn (seconds) or ExpiresAt (a date) is the token's lifetime, as
|
|
// Qoder's CLI reads it; either may be missing.
|
|
ExpiresIn int64 `json:"expires_in,omitempty"`
|
|
ExpiresAt string `json:"expires_at,omitempty"`
|
|
}
|
|
|
|
// Expiry is when the device token lapses; zero when Qoder said nothing.
|
|
func (t DeviceToken) Expiry() time.Time {
|
|
if at, err := time.Parse(time.RFC3339, strings.TrimSpace(t.ExpiresAt)); err == nil {
|
|
return at
|
|
}
|
|
if t.ExpiresIn > 0 {
|
|
return time.Now().Add(time.Duration(t.ExpiresIn) * time.Second)
|
|
}
|
|
return time.Time{}
|
|
}
|
|
|
|
// JobToken is the jt- task token that authorizes the model calls.
|
|
type JobToken struct {
|
|
Token string `json:"token"`
|
|
RefreshToken string `json:"refresh_token"`
|
|
// ExpiresIn is Qoder's lifetime for the token, in milliseconds.
|
|
ExpiresIn int64 `json:"expires_in"`
|
|
}
|
|
|
|
// Expiry is the job token's lifetime as a duration; zero when Qoder said none.
|
|
func (r JobToken) Expiry() time.Duration {
|
|
if r.ExpiresIn <= 0 {
|
|
return 0
|
|
}
|
|
return time.Duration(r.ExpiresIn) * time.Millisecond
|
|
}
|
|
|
|
// DeviceFlow runs Qoder's sign-in: an authorization page the user opens, then
|
|
// a poll for the device token, then its exchange for the job token that serves
|
|
// models. It is a helper, not a state machine: the caller owns the browser and
|
|
// the waiting, and calls these one at a time.
|
|
type DeviceFlow struct {
|
|
client *http.Client
|
|
site *Site
|
|
machineID string
|
|
}
|
|
|
|
// NewDeviceFlow makes a flow on site (nil is the global one); a nil client is
|
|
// a 20-second-timeout one.
|
|
func NewDeviceFlow(client *http.Client, site *Site) *DeviceFlow {
|
|
if client == nil {
|
|
client = &http.Client{Timeout: 20 * time.Second}
|
|
}
|
|
if site == nil {
|
|
site = Global
|
|
}
|
|
return &DeviceFlow{client: client, site: site, machineID: newUUID()}
|
|
}
|
|
|
|
// Site is the Qoder site the flow signs in on.
|
|
func (f *DeviceFlow) Site() *Site { return f.site }
|
|
|
|
// Client is the flow's HTTP client, reused for the model fetch after sign-in.
|
|
func (f *DeviceFlow) Client() *http.Client { return f.client }
|
|
|
|
func (f *DeviceFlow) MachineID() string { return f.machineID }
|
|
|
|
// Authorization returns the page to open and the (verifier, nonce) the poll
|
|
// needs. It is a PKCE device flow: a random verifier is kept, its S256 digest
|
|
// is what the page sees.
|
|
func (f *DeviceFlow) Authorization() (authURL, verifier, nonce string, err error) {
|
|
raw := make([]byte, 64)
|
|
if _, err = rand.Read(raw); err != nil {
|
|
return "", "", "", fmt.Errorf("qoder auth url: random verifier: %w", err)
|
|
}
|
|
verifier = base64.RawURLEncoding.EncodeToString(raw)
|
|
sum := sha256.Sum256([]byte(verifier))
|
|
challenge := base64.RawURLEncoding.EncodeToString(sum[:])
|
|
nonce = newUUID()
|
|
q := url.Values{}
|
|
q.Set("challenge", challenge)
|
|
q.Set("challenge_method", "S256")
|
|
q.Set("nonce", nonce)
|
|
q.Set("machine_id", f.machineID)
|
|
q.Set("client_id", f.site.ClientID)
|
|
if f.site.RedirectURI != "" {
|
|
q.Set("redirect_uri", f.site.RedirectURI)
|
|
}
|
|
return f.site.Web + DeviceSelectAccountsPath + "?" + q.Encode(), verifier, nonce, nil
|
|
}
|
|
|
|
// PollDeviceToken asks for the device token until the user has authorized it,
|
|
// ctx ends the wait, or interval elapses between tries.
|
|
func (f *DeviceFlow) PollDeviceToken(ctx context.Context, nonce, verifier string, interval time.Duration) (*DeviceToken, error) {
|
|
if interval <= 0 {
|
|
interval = 2 * time.Second
|
|
}
|
|
endpoint := f.site.OpenAPI + DeviceTokenPollPath
|
|
for {
|
|
if err := ctx.Err(); err != nil {
|
|
return nil, fmt.Errorf("qoder device poll: %w", err)
|
|
}
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("qoder device poll: create request: %w", err)
|
|
}
|
|
q := req.URL.Query()
|
|
q.Set("nonce", nonce)
|
|
q.Set("verifier", verifier)
|
|
q.Set("challenge_method", "S256")
|
|
req.URL.RawQuery = q.Encode()
|
|
req.Header.Set("Accept", "application/json")
|
|
resp, err := f.client.Do(req)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("qoder device poll: request failed: %w", err)
|
|
}
|
|
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
|
resp.Body.Close()
|
|
if resp.StatusCode == http.StatusOK {
|
|
var t DeviceToken
|
|
if json.Unmarshal(body, &t) == nil && strings.TrimSpace(t.Token) != "" {
|
|
return &t, nil
|
|
}
|
|
}
|
|
select {
|
|
case <-ctx.Done():
|
|
return nil, fmt.Errorf("qoder device poll: %w", ctx.Err())
|
|
case <-time.After(interval):
|
|
}
|
|
}
|
|
}
|
|
|
|
// JobToken trades a device token for the job token the model calls use.
|
|
func (f *DeviceFlow) JobToken(ctx context.Context, deviceToken string) (*JobToken, error) {
|
|
body, _ := json.Marshal(map[string]string{"clientId": f.site.ClientID})
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, f.site.OpenAPI+JobTokenPath, bytes.NewReader(body))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("qoder job token: create request: %w", err)
|
|
}
|
|
req.Header.Set("Accept", "application/json")
|
|
req.Header.Set("Content-Type", "application/json")
|
|
req.Header.Set("Authorization", "Bearer "+deviceToken)
|
|
resp, err := f.client.Do(req)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("qoder job token: request failed: %w", err)
|
|
}
|
|
raw, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
|
resp.Body.Close()
|
|
if resp.StatusCode != http.StatusOK {
|
|
return nil, &JobTokenHTTPError{StatusCode: resp.StatusCode, Body: sanitize(raw)}
|
|
}
|
|
var t JobToken
|
|
if err := json.Unmarshal(raw, &t); err != nil {
|
|
return nil, fmt.Errorf("qoder job token: decode: %w", err)
|
|
}
|
|
if strings.TrimSpace(t.Token) == "" {
|
|
return nil, fmt.Errorf("qoder job token: empty token in response")
|
|
}
|
|
return &t, nil
|
|
}
|
|
|
|
// JobTokenHTTPError is a refused device-to-job token exchange.
|
|
type JobTokenHTTPError struct {
|
|
StatusCode int
|
|
Body string
|
|
}
|
|
|
|
func (e *JobTokenHTTPError) Error() string {
|
|
return fmt.Sprintf("qoder job token: status %d: %s", e.StatusCode, e.Body)
|
|
}
|
|
|
|
// RefreshJobToken trades a job token's refresh token for a new pair on site
|
|
// (nil is the global one). The old refresh token is spent, so the caller must
|
|
// keep the new one.
|
|
func RefreshJobToken(ctx context.Context, client *http.Client, site *Site, refreshToken string) (*JobToken, error) {
|
|
if strings.TrimSpace(refreshToken) == "" {
|
|
return nil, fmt.Errorf("qoder job token refresh: missing refresh token; sign in again")
|
|
}
|
|
if client == nil {
|
|
client = &http.Client{Timeout: 20 * time.Second}
|
|
}
|
|
if site == nil {
|
|
site = Global
|
|
}
|
|
return postJobRefresh(ctx, client, site.OpenAPI+JobTokenRefreshPath, refreshToken)
|
|
}
|
|
|
|
func postJobRefresh(ctx context.Context, client *http.Client, endpoint, refreshToken string) (*JobToken, error) {
|
|
body, _ := json.Marshal(map[string]string{"refresh_token": refreshToken})
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("qoder job token refresh: create request: %w", err)
|
|
}
|
|
req.Header.Set("Accept", "application/json")
|
|
req.Header.Set("Content-Type", "application/json")
|
|
resp, err := client.Do(req)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("qoder job token refresh: request failed: %w", err)
|
|
}
|
|
raw, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
|
resp.Body.Close()
|
|
if resp.StatusCode != http.StatusOK {
|
|
return nil, &JobTokenRefreshHTTPError{StatusCode: resp.StatusCode}
|
|
}
|
|
var t JobToken
|
|
if err := json.Unmarshal(raw, &t); err != nil {
|
|
return nil, fmt.Errorf("qoder job token refresh: decode: %w", err)
|
|
}
|
|
if strings.TrimSpace(t.Token) == "" || strings.TrimSpace(t.RefreshToken) == "" {
|
|
return nil, fmt.Errorf("qoder job token refresh: incomplete token pair")
|
|
}
|
|
return &t, nil
|
|
}
|
|
|
|
// JobTokenRefreshHTTPError preserves a rejected job-token refresh status.
|
|
type JobTokenRefreshHTTPError struct {
|
|
StatusCode int
|
|
}
|
|
|
|
func (e *JobTokenRefreshHTTPError) Error() string {
|
|
return fmt.Sprintf("qoder job token refresh: status %d", e.StatusCode)
|
|
}
|
|
|
|
// DeviceTokenRefreshHTTPError preserves a rejected refresh status without response data.
|
|
type DeviceTokenRefreshHTTPError struct {
|
|
StatusCode int
|
|
}
|
|
|
|
func (e *DeviceTokenRefreshHTTPError) Error() string {
|
|
return fmt.Sprintf("qoder device token refresh: upstream HTTP %d", e.StatusCode)
|
|
}
|
|
|
|
// RefreshDeviceToken rotates the device token used by account endpoints; an
|
|
// empty endpoint is the global site's.
|
|
func RefreshDeviceToken(ctx context.Context, client *http.Client, endpoint, refreshToken string) (*DeviceToken, error) {
|
|
if strings.TrimSpace(refreshToken) == "" {
|
|
return nil, fmt.Errorf("qoder device token refresh: missing refresh token; sign in again")
|
|
}
|
|
if client == nil {
|
|
client = &http.Client{Timeout: 20 * time.Second}
|
|
}
|
|
if endpoint == "" {
|
|
endpoint = Global.OpenAPI + DeviceTokenRefreshPath
|
|
}
|
|
body, err := json.Marshal(map[string]string{"refresh_token": refreshToken})
|
|
if err != nil {
|
|
return nil, fmt.Errorf("qoder device token refresh: marshal: %w", err)
|
|
}
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, strings.NewReader(string(body)))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("qoder device token refresh: create request: %w", err)
|
|
}
|
|
req.Header.Set("Accept", "application/json")
|
|
req.Header.Set("Content-Type", "application/json")
|
|
resp, err := client.Do(req)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("qoder device token refresh: request failed: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode != http.StatusOK {
|
|
return nil, &DeviceTokenRefreshHTTPError{StatusCode: resp.StatusCode}
|
|
}
|
|
var token DeviceToken
|
|
if err := json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&token); err != nil {
|
|
return nil, fmt.Errorf("qoder device token refresh: decode: %w", err)
|
|
}
|
|
if token.Token == "" {
|
|
token.Token = token.DeviceToken
|
|
}
|
|
if strings.TrimSpace(token.Token) == "" || strings.TrimSpace(token.RefreshToken) == "" {
|
|
return nil, fmt.Errorf("qoder device token refresh: incomplete token pair")
|
|
}
|
|
return &token, nil
|
|
}
|