1package auth
2
3import (
4 "bytes"
5 "context"
6 "encoding/json"
7 "fmt"
8 "io"
9 "net/http"
10 "time"
11)
12
13// KimiDeviceAuthorization is the OAuth 2 device-code response used by
14// the official Kimi Code CLI.
15type KimiDeviceAuthorization struct {
16 UserCode string `json:"user_code"`
17 DeviceCode string `json:"device_code"`
18 VerificationURI string `json:"verification_uri"`
19 VerificationURIComplete string `json:"verification_uri_complete"`
20 ExpiresIn int `json:"expires_in"`
21 Interval int `json:"interval"`
22}
23
24// RequestKimiDeviceAuthorization starts Kimi Code's device-code login.
25func RequestKimiDeviceAuthorization(ctx context.Context) (KimiDeviceAuthorization, error) {
26 form := bytes.NewBufferString("client_id=" + KimiOAuth.ClientID)
27 req, err := http.NewRequestWithContext(ctx, "POST", "https://auth.kimi.com/api/oauth/device_authorization", form)
28 if err != nil {
29 return KimiDeviceAuthorization{}, err
30 }
31 req.Header.Set("content-type", "application/x-www-form-urlencoded")
32 req.Header.Set("accept", "application/json")
33 req.Header.Set("user-agent", "zut")
34
35 resp, err := (&http.Client{Timeout: 30 * time.Second}).Do(req)
36 if err != nil {
37 return KimiDeviceAuthorization{}, fmt.Errorf("kimi device authorization: %w", err)
38 }
39 defer resp.Body.Close()
40 body, _ := io.ReadAll(resp.Body)
41 if resp.StatusCode < 200 || resp.StatusCode >= 300 {
42 return KimiDeviceAuthorization{}, fmt.Errorf("kimi device authorization http %d: %s", resp.StatusCode, string(body))
43 }
44 var out KimiDeviceAuthorization
45 if err := json.Unmarshal(body, &out); err != nil {
46 return out, fmt.Errorf("parse kimi device authorization: %w", err)
47 }
48 if out.Interval <= 0 {
49 out.Interval = 5
50 }
51 return out, nil
52}
53
54// PollKimiDeviceToken polls until the browser/device-code login completes.
55func PollKimiDeviceToken(ctx context.Context, auth KimiDeviceAuthorization) (*OAuthToken, error) {
56 for {
57 tok, retry, err := pollKimiDeviceTokenOnce(ctx, auth.DeviceCode)
58 if err != nil {
59 return nil, err
60 }
61 if tok != nil {
62 return tok, nil
63 }
64 select {
65 case <-ctx.Done():
66 return nil, ctx.Err()
67 case <-time.After(retry):
68 }
69 }
70}
71
72func pollKimiDeviceTokenOnce(ctx context.Context, deviceCode string) (*OAuthToken, time.Duration, error) {
73 form := bytes.NewBufferString("client_id=" + KimiOAuth.ClientID + "&device_code=" + deviceCode + "&grant_type=urn:ietf:params:oauth:grant-type:device_code")
74 req, err := http.NewRequestWithContext(ctx, "POST", KimiOAuth.TokenURL, form)
75 if err != nil {
76 return nil, 0, err
77 }
78 req.Header.Set("content-type", "application/x-www-form-urlencoded")
79 req.Header.Set("accept", "application/json")
80 req.Header.Set("user-agent", "zut")
81 resp, err := (&http.Client{Timeout: 30 * time.Second}).Do(req)
82 if err != nil {
83 return nil, 0, fmt.Errorf("kimi token poll: %w", err)
84 }
85 defer resp.Body.Close()
86 body, _ := io.ReadAll(resp.Body)
87 var raw struct {
88 AccessToken string `json:"access_token"`
89 RefreshToken string `json:"refresh_token"`
90 TokenType string `json:"token_type"`
91 ExpiresIn float64 `json:"expires_in"`
92 Scope string `json:"scope"`
93 Error string `json:"error"`
94 ErrorDescription string `json:"error_description"`
95 }
96 _ = json.Unmarshal(body, &raw)
97 if resp.StatusCode == http.StatusOK && raw.AccessToken != "" {
98 return &OAuthToken{
99 AccessToken: raw.AccessToken,
100 RefreshToken: raw.RefreshToken,
101 TokenType: raw.TokenType,
102 Scope: raw.Scope,
103 ClientID: KimiOAuth.ClientID,
104 Expiry: time.Now().Add(time.Duration(raw.ExpiresIn) * time.Second),
105 }, 0, nil
106 }
107 if raw.Error == "authorization_pending" || raw.Error == "slow_down" || resp.StatusCode == http.StatusBadRequest {
108 return nil, 5 * time.Second, nil
109 }
110 if raw.Error != "" {
111 return nil, 0, fmt.Errorf("kimi token poll: %s: %s", raw.Error, raw.ErrorDescription)
112 }
113 return nil, 0, fmt.Errorf("kimi token poll http %d: %s", resp.StatusCode, string(body))
114}