Backend:
- Add Kimi OAuth service (InitiateDeviceAuth, PollToken, RefreshToken)
- Add Kimi token provider with caching and auto-refresh
- Add Kimi gateway service for OpenAI-compatible chat completions
- Add admin routes for /kimi/oauth/* endpoints
- Wire up all new services
- Add PlatformKimi to error passthrough rule constants
- Add 'kimi' to group handler platform validation tags
- Fix httpclient usage in Kimi OAuth service (use GetClient with opts)
- Add CanRefresh method to KimiTokenRefresher
- Remove duplicate ProvideKimiTokenProvider from wire ProviderSet
- Remove unused imports
- Lower go.mod requirement to 1.26.1 for local toolchain compatibility
- Add verification_uri_complete support for seamless device auth
Frontend:
- Add useKimiOAuth composable for device flow
- Integrate Kimi device flow UI in CreateAccountModal Step 2
- Add kimi to GroupPlatform and AccountPlatform types
- Add i18n translations for device flow (zh/en)
- Fix Icon name lock-closed -> key
- Add kimi platform colors/styles to platformColors.ts
- Add kimi to AccountTableFilters platform options
- Add kimi to ChannelsView platformOrder
- Use verification_uri_complete for auto-filled user_code
[宪宪/Kimi-1.6🐾]
307 lines
9.6 KiB
Go
307 lines
9.6 KiB
Go
package service
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/url"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/Wei-Shaw/sub2api/internal/pkg/httpclient"
|
|
"github.com/Wei-Shaw/sub2api/internal/pkg/logger"
|
|
)
|
|
|
|
const (
|
|
kimiAuthBaseURL = "https://auth.kimi.com"
|
|
kimiDeviceAuthURL = kimiAuthBaseURL + "/api/oauth/device_authorization"
|
|
kimiTokenURL = kimiAuthBaseURL + "/api/oauth/token"
|
|
kimiClientID = "17e5f671-d194-4dfb-9706-5516cb48c098"
|
|
kimiDefaultScope = "kimi-code"
|
|
KimiTokenSafetyWindow = 300 // 5 minutes
|
|
KimiTokenMinTTL = 30 // 30 seconds
|
|
)
|
|
|
|
// KimiDeviceAuthResponse represents the response from the device authorization endpoint.
|
|
type KimiDeviceAuthResponse struct {
|
|
DeviceCode string `json:"device_code"`
|
|
UserCode string `json:"user_code"`
|
|
VerificationURI string `json:"verification_uri"`
|
|
VerificationURIComplete string `json:"verification_uri_complete"`
|
|
ExpiresIn int64 `json:"expires_in"`
|
|
Interval int64 `json:"interval"`
|
|
}
|
|
|
|
// KimiTokenResponse represents the response from the token endpoint.
|
|
type KimiTokenResponse struct {
|
|
AccessToken string `json:"access_token"`
|
|
RefreshToken string `json:"refresh_token"`
|
|
ExpiresIn int64 `json:"expires_in"`
|
|
Scope string `json:"scope"`
|
|
TokenType string `json:"token_type"`
|
|
}
|
|
|
|
// KimiTokenInfo holds token information for an account.
|
|
type KimiTokenInfo struct {
|
|
AccessToken string `json:"access_token"`
|
|
RefreshToken string `json:"refresh_token"`
|
|
ExpiresIn int64 `json:"expires_in"`
|
|
ExpiresAt int64 `json:"expires_at"`
|
|
Scope string `json:"scope,omitempty"`
|
|
TokenType string `json:"token_type,omitempty"`
|
|
}
|
|
|
|
// KimiOAuthService handles Kimi OAuth device flow and token refresh.
|
|
type KimiOAuthService struct {
|
|
sessions sync.Map // device_code -> *kimiOAuthSession
|
|
}
|
|
|
|
type kimiOAuthSession struct {
|
|
DeviceCode string
|
|
UserCode string
|
|
VerificationURI string
|
|
VerificationURIComplete string
|
|
ExpiresAt time.Time
|
|
Interval time.Duration
|
|
ProxyURL string
|
|
}
|
|
|
|
// NewKimiOAuthService creates a new KimiOAuthService.
|
|
func NewKimiOAuthService() *KimiOAuthService {
|
|
return &KimiOAuthService{}
|
|
}
|
|
|
|
// InitiateDeviceAuth starts the OAuth 2.0 Device Authorization Grant flow.
|
|
func (s *KimiOAuthService) InitiateDeviceAuth(ctx context.Context, proxyID *int64, proxyRepo ProxyRepository) (*KimiDeviceAuthResponse, error) {
|
|
data := url.Values{}
|
|
data.Set("client_id", kimiClientID)
|
|
data.Set("scope", kimiDefaultScope)
|
|
|
|
var proxyURL string
|
|
if proxyID != nil && proxyRepo != nil {
|
|
if proxy, err := proxyRepo.GetByID(ctx, *proxyID); err == nil && proxy != nil {
|
|
proxyURL = proxy.URL()
|
|
}
|
|
}
|
|
|
|
req, err := http.NewRequestWithContext(ctx, "POST", kimiDeviceAuthURL, strings.NewReader(data.Encode()))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to create device auth request: %w", err)
|
|
}
|
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
req.Header.Set("Accept", "application/json")
|
|
|
|
resp, err := s.doRequest(req, proxyURL)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("device auth request failed: %w", err)
|
|
}
|
|
defer func() { _ = resp.Body.Close() }()
|
|
|
|
body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to read device auth response: %w", err)
|
|
}
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
return nil, fmt.Errorf("device auth failed: status=%d body=%s", resp.StatusCode, string(body))
|
|
}
|
|
|
|
var result KimiDeviceAuthResponse
|
|
if err := json.Unmarshal(body, &result); err != nil {
|
|
return nil, fmt.Errorf("failed to parse device auth response: %w", err)
|
|
}
|
|
|
|
interval := time.Duration(result.Interval) * time.Second
|
|
if interval <= 0 {
|
|
interval = 5 * time.Second
|
|
}
|
|
|
|
session := &kimiOAuthSession{
|
|
DeviceCode: result.DeviceCode,
|
|
UserCode: result.UserCode,
|
|
VerificationURI: result.VerificationURI,
|
|
VerificationURIComplete: result.VerificationURIComplete,
|
|
ExpiresAt: time.Now().Add(time.Duration(result.ExpiresIn) * time.Second),
|
|
Interval: interval,
|
|
ProxyURL: proxyURL,
|
|
}
|
|
s.sessions.Store(result.DeviceCode, session)
|
|
|
|
logger.LegacyPrintf("service.kimi_oauth", "[KimiOAuth] Device auth initiated: user_code=%s", result.UserCode)
|
|
return &result, nil
|
|
}
|
|
|
|
// PollToken polls the token endpoint for a device_code.
|
|
func (s *KimiOAuthService) PollToken(ctx context.Context, deviceCode string) (*KimiTokenInfo, error) {
|
|
sessionVal, ok := s.sessions.Load(deviceCode)
|
|
if !ok {
|
|
return nil, fmt.Errorf("session not found or expired")
|
|
}
|
|
session := sessionVal.(*kimiOAuthSession)
|
|
|
|
if time.Now().After(session.ExpiresAt) {
|
|
s.sessions.Delete(deviceCode)
|
|
return nil, fmt.Errorf("device auth session expired")
|
|
}
|
|
|
|
data := url.Values{}
|
|
data.Set("client_id", kimiClientID)
|
|
data.Set("grant_type", "urn:ietf:params:oauth:grant-type:device_code")
|
|
data.Set("device_code", deviceCode)
|
|
|
|
req, err := http.NewRequestWithContext(ctx, "POST", kimiTokenURL, strings.NewReader(data.Encode()))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to create token request: %w", err)
|
|
}
|
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
req.Header.Set("Accept", "application/json")
|
|
|
|
resp, err := s.doRequest(req, session.ProxyURL)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("token request failed: %w", err)
|
|
}
|
|
defer func() { _ = resp.Body.Close() }()
|
|
|
|
body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to read token response: %w", err)
|
|
}
|
|
|
|
if resp.StatusCode == http.StatusBadRequest || resp.StatusCode == http.StatusForbidden {
|
|
// Check for authorization_pending
|
|
var errResp struct {
|
|
Error string `json:"error"`
|
|
}
|
|
if json.Unmarshal(body, &errResp) == nil && errResp.Error == "authorization_pending" {
|
|
return nil, fmt.Errorf("authorization_pending")
|
|
}
|
|
if errResp.Error == "expired_token" || errResp.Error == "invalid_grant" {
|
|
s.sessions.Delete(deviceCode)
|
|
}
|
|
return nil, fmt.Errorf("token poll failed: %s", errResp.Error)
|
|
}
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
return nil, fmt.Errorf("token poll failed: status=%d body=%s", resp.StatusCode, string(body))
|
|
}
|
|
|
|
var tokenResp KimiTokenResponse
|
|
if err := json.Unmarshal(body, &tokenResp); err != nil {
|
|
return nil, fmt.Errorf("failed to parse token response: %w", err)
|
|
}
|
|
|
|
s.sessions.Delete(deviceCode)
|
|
|
|
expiresAt := time.Now().Unix() + tokenResp.ExpiresIn - KimiTokenSafetyWindow
|
|
minExpiresAt := time.Now().Unix() + KimiTokenMinTTL
|
|
if expiresAt < minExpiresAt {
|
|
expiresAt = minExpiresAt
|
|
}
|
|
|
|
logger.LegacyPrintf("service.kimi_oauth", "[KimiOAuth] Token obtained successfully")
|
|
return &KimiTokenInfo{
|
|
AccessToken: tokenResp.AccessToken,
|
|
RefreshToken: tokenResp.RefreshToken,
|
|
ExpiresIn: tokenResp.ExpiresIn,
|
|
ExpiresAt: expiresAt,
|
|
Scope: tokenResp.Scope,
|
|
TokenType: tokenResp.TokenType,
|
|
}, nil
|
|
}
|
|
|
|
// RefreshToken refreshes the access token using a refresh token.
|
|
func (s *KimiOAuthService) RefreshToken(ctx context.Context, refreshToken, proxyURL string) (*KimiTokenInfo, error) {
|
|
if strings.TrimSpace(refreshToken) == "" {
|
|
return nil, fmt.Errorf("refresh_token is empty")
|
|
}
|
|
|
|
data := url.Values{}
|
|
data.Set("client_id", kimiClientID)
|
|
data.Set("grant_type", "refresh_token")
|
|
data.Set("refresh_token", refreshToken)
|
|
|
|
req, err := http.NewRequestWithContext(ctx, "POST", kimiTokenURL, strings.NewReader(data.Encode()))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to create refresh request: %w", err)
|
|
}
|
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
req.Header.Set("Accept", "application/json")
|
|
|
|
resp, err := s.doRequest(req, proxyURL)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("refresh request failed: %w", err)
|
|
}
|
|
defer func() { _ = resp.Body.Close() }()
|
|
|
|
body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to read refresh response: %w", err)
|
|
}
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
return nil, fmt.Errorf("refresh failed: status=%d body=%s", resp.StatusCode, string(body))
|
|
}
|
|
|
|
var tokenResp KimiTokenResponse
|
|
if err := json.Unmarshal(body, &tokenResp); err != nil {
|
|
return nil, fmt.Errorf("failed to parse refresh response: %w", err)
|
|
}
|
|
|
|
expiresAt := time.Now().Unix() + tokenResp.ExpiresIn - KimiTokenSafetyWindow
|
|
minExpiresAt := time.Now().Unix() + KimiTokenMinTTL
|
|
if expiresAt < minExpiresAt {
|
|
expiresAt = minExpiresAt
|
|
}
|
|
|
|
return &KimiTokenInfo{
|
|
AccessToken: tokenResp.AccessToken,
|
|
RefreshToken: tokenResp.RefreshToken,
|
|
ExpiresIn: tokenResp.ExpiresIn,
|
|
ExpiresAt: expiresAt,
|
|
Scope: tokenResp.Scope,
|
|
TokenType: tokenResp.TokenType,
|
|
}, nil
|
|
}
|
|
|
|
// BuildAccountCredentials builds the credentials map for a Kimi account.
|
|
func (s *KimiOAuthService) BuildAccountCredentials(tokenInfo *KimiTokenInfo) map[string]any {
|
|
creds := map[string]any{
|
|
"access_token": tokenInfo.AccessToken,
|
|
"expires_at": strconv.FormatInt(tokenInfo.ExpiresAt, 10),
|
|
}
|
|
if tokenInfo.RefreshToken != "" {
|
|
creds["refresh_token"] = tokenInfo.RefreshToken
|
|
}
|
|
if tokenInfo.TokenType != "" {
|
|
creds["token_type"] = tokenInfo.TokenType
|
|
}
|
|
if tokenInfo.Scope != "" {
|
|
creds["scope"] = tokenInfo.Scope
|
|
}
|
|
return creds
|
|
}
|
|
|
|
// Stop cleans up the service.
|
|
func (s *KimiOAuthService) Stop() {
|
|
s.sessions.Range(func(key, value any) bool {
|
|
s.sessions.Delete(key)
|
|
return true
|
|
})
|
|
}
|
|
|
|
func (s *KimiOAuthService) doRequest(req *http.Request, proxyURL string) (*http.Response, error) {
|
|
opts := httpclient.Options{
|
|
ProxyURL: proxyURL,
|
|
Timeout: 30 * time.Second,
|
|
}
|
|
client, err := httpclient.GetClient(opts)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return client.Do(req)
|
|
}
|