Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
83 changes: 83 additions & 0 deletions cmd/capi/antigravity.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,14 @@ const (
antigravityOAuthClientID = "1071006060591-tmhssin2h21lcre235vtolojh4g403ep.apps.googleusercontent.com"
antigravityTokenEndpoint = "https://oauth2.googleapis.com/token"

// Local-authorization (loopback) OAuth. The admin opens the Google consent
// page, logs in, and pastes the localhost callback URL back — the same
// copy-paste flow codex uses. The Antigravity client is a Google "Desktop
// app" OAuth client, so Google accepts any http://localhost redirect.
antigravityOAuthAuthorizeEndpoint = "https://accounts.google.com/o/oauth2/v2/auth"
antigravityOAuthRedirectURI = "http://localhost:8788/oauth2callback"
antigravityOAuthScope = "openid https://www.googleapis.com/auth/userinfo.email https://www.googleapis.com/auth/userinfo.profile https://www.googleapis.com/auth/cloud-platform"

// Consumer credentials default to the daily host for generateContent while
// project discovery (loadCodeAssist) goes to the production host.
antigravityBaseURLDaily = "https://daily-cloudcode-pa.googleapis.com"
Expand Down Expand Up @@ -245,6 +253,81 @@ func (s *Server) refreshAntigravityAccount(refreshToken string) (OpenAIRefreshRe
return result, nil
}

// antigravityAuthorizeURL builds the Google OAuth consent URL for the local
// authorization flow. access_type=offline + prompt=consent force Google to
// return a refresh_token even when the account has consented before.
func antigravityAuthorizeURL(challenge, state string) string {
params := url.Values{}
params.Set("response_type", "code")
params.Set("client_id", antigravityOAuthClientID)
params.Set("redirect_uri", antigravityOAuthRedirectURI)
params.Set("scope", antigravityOAuthScope)
params.Set("code_challenge", challenge)
params.Set("code_challenge_method", "S256")
params.Set("access_type", "offline")
params.Set("prompt", "consent")
params.Set("state", state)
return antigravityOAuthAuthorizeEndpoint + "?" + params.Encode()
}

// exchangeAntigravityOAuthCode exchanges a Google authorization code + PKCE
// verifier for Antigravity (Cloud Code) tokens, mirroring the refresh call but
// with grant_type=authorization_code.
func (s *Server) exchangeAntigravityOAuthCode(code, verifier string) (OpenAIRefreshResult, error) {
form := url.Values{}
form.Set("grant_type", "authorization_code")
form.Set("client_id", antigravityOAuthClientID)
form.Set("client_secret", antigravityOAuthClientSecret)
form.Set("code", code)
form.Set("redirect_uri", antigravityOAuthRedirectURI)
form.Set("code_verifier", verifier)

request, err := http.NewRequest(http.MethodPost, antigravityTokenEndpoint, strings.NewReader(form.Encode()))
if err != nil {
return OpenAIRefreshResult{}, err
}
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
request.Header.Set("Accept", "application/json")

response, err := s.httpClient.Do(request)
if err != nil {
return OpenAIRefreshResult{}, err
}
defer response.Body.Close()

content, err := io.ReadAll(io.LimitReader(response.Body, 1<<20))
if err != nil {
return OpenAIRefreshResult{}, err
}
if response.StatusCode < 200 || response.StatusCode >= 300 {
providerErr := providerErrorFromUpstream(response.StatusCode, content)
return OpenAIRefreshResult{}, fmt.Errorf("%s", providerErr.Message)
}

var body struct {
AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token"`
IDToken string `json:"id_token"`
ExpiresIn int `json:"expires_in"`
TokenType string `json:"token_type"`
}
if err := json.Unmarshal(content, &body); err != nil {
return OpenAIRefreshResult{}, err
}
if strings.TrimSpace(body.AccessToken) == "" {
return OpenAIRefreshResult{}, fmt.Errorf("授权响应缺少 access_token")
}
result := OpenAIRefreshResult{
AccessToken: strings.TrimSpace(body.AccessToken),
RefreshToken: strings.TrimSpace(body.RefreshToken),
IDToken: strings.TrimSpace(body.IDToken),
}
if body.ExpiresIn > 0 {
result.ExpiresAt = time.Now().Add(time.Duration(body.ExpiresIn) * time.Second).UTC().Format(time.RFC3339Nano)
}
return result, nil
}

// resolveAntigravityProject discovers the GCP project bound to the account via
// loadCodeAssist. The project is required on every generateContent request.
func (s *Server) resolveAntigravityProject(account OpenAIAccount, accessToken string) (string, error) {
Expand Down
33 changes: 33 additions & 0 deletions cmd/capi/antigravity_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package main

import (
"encoding/json"
"net/url"
"strings"
"testing"

Expand Down Expand Up @@ -179,3 +180,35 @@ func TestAntigravityClientSecretDecodes(t *testing.T) {
t.Fatalf("client secret decoded to garbage")
}
}

func TestAntigravityAuthorizeURL(t *testing.T) {
raw := antigravityAuthorizeURL("chal-123", "state-abc")
parsed, err := url.Parse(raw)
if err != nil {
t.Fatalf("authorize URL does not parse: %v", err)
}
if got := parsed.Scheme + "://" + parsed.Host + parsed.Path; got != antigravityOAuthAuthorizeEndpoint {
t.Fatalf("authorize endpoint = %q, want %q", got, antigravityOAuthAuthorizeEndpoint)
}
query := parsed.Query()
checks := map[string]string{
"response_type": "code",
"client_id": antigravityOAuthClientID,
"redirect_uri": antigravityOAuthRedirectURI,
"scope": antigravityOAuthScope,
"code_challenge": "chal-123",
"code_challenge_method": "S256",
"access_type": "offline",
"prompt": "consent",
"state": "state-abc",
}
for key, want := range checks {
if got := query.Get(key); got != want {
t.Fatalf("authorize param %q = %q, want %q", key, got, want)
}
}
// A refresh token is only issued when cloud-platform scope is granted.
if !strings.Contains(query.Get("scope"), "cloud-platform") {
t.Fatalf("scope must request cloud-platform, got %q", query.Get("scope"))
}
}
138 changes: 138 additions & 0 deletions cmd/capi/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -957,6 +957,8 @@ func (s *Server) registerRoutes(router *gin.Engine) {
admin.POST("/channels/:id/import-openai-accounts", s.importOpenAIAccounts)
admin.POST("/channels/:id/openai-oauth/start", s.startOpenAIOAuth)
admin.POST("/channels/:id/openai-oauth/complete", s.completeOpenAIOAuth)
admin.POST("/channels/:id/antigravity-oauth/start", s.startAntigravityOAuth)
admin.POST("/channels/:id/antigravity-oauth/complete", s.completeAntigravityOAuth)
admin.POST("/channels/:id/openai-accounts/check", s.checkOpenAIAccounts)
admin.POST("/channels/:id/openai-accounts/deduplicate", s.deduplicateOpenAIAccounts)
admin.DELETE("/channels/:id/openai-accounts/:accountId", s.deleteOpenAIAccount)
Expand Down Expand Up @@ -3506,6 +3508,142 @@ func (s *Server) completeOpenAIOAuth(c *gin.Context) {
})
}

// startAntigravityOAuth begins a Google OAuth (PKCE) local authorization for an
// Antigravity channel. It mirrors startOpenAIOAuth but targets Google's consent
// endpoint; the resulting account carries a Google refresh token.
func (s *Server) startAntigravityOAuth(c *gin.Context) {
s.mu.Lock()
channel := s.findChannel(c.Param("id"))
if channel == nil {
s.mu.Unlock()
c.JSON(http.StatusNotFound, gin.H{"error": gin.H{"message": "Channel not found"}})
return
}
s.mu.Unlock()

verifier, challenge, err := newPKCEPair()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": gin.H{"message": "Failed to create PKCE challenge"}})
return
}
state := randomHex(24)

s.mu.Lock()
s.pruneOpenAIOAuthFlowsLocked()
s.openAIOAuthFlows[state] = openAIOAuthFlow{CodeVerifier: verifier, CreatedAt: time.Now().UTC()}
s.mu.Unlock()

c.JSON(http.StatusOK, gin.H{
"authorizeUrl": antigravityAuthorizeURL(challenge, state),
"state": state,
"redirectUri": antigravityOAuthRedirectURI,
})
}

// completeAntigravityOAuth finishes the Google OAuth flow: it verifies the state,
// exchanges the code with the stored PKCE verifier, and stores the resulting
// Antigravity account (with its Google refresh token) in the channel's pool.
func (s *Server) completeAntigravityOAuth(c *gin.Context) {
var body struct {
CallbackURL string `json:"callbackUrl"`
Code string `json:"code"`
State string `json:"state"`
}
_ = c.ShouldBindJSON(&body)

code := strings.TrimSpace(body.Code)
state := strings.TrimSpace(body.State)
if raw := strings.TrimSpace(body.CallbackURL); raw != "" {
if parsed, err := url.Parse(raw); err == nil {
query := parsed.Query()
if v := strings.TrimSpace(query.Get("code")); v != "" {
code = v
}
if v := strings.TrimSpace(query.Get("state")); v != "" {
state = v
}
}
}
if code == "" {
validationError(c, "缺少授权 code,请粘贴完整回调地址")
return
}

s.mu.Lock()
channel := s.findChannel(c.Param("id"))
if channel == nil {
s.mu.Unlock()
c.JSON(http.StatusNotFound, gin.H{"error": gin.H{"message": "Channel not found"}})
return
}
flow, ok := s.openAIOAuthFlows[state]
if ok {
delete(s.openAIOAuthFlows, state)
}
s.mu.Unlock()
if !ok {
validationError(c, "授权状态已失效,请重新发起授权")
return
}

tokens, err := s.exchangeAntigravityOAuthCode(code, flow.CodeVerifier)
if err != nil {
s.openAIError(c, http.StatusBadGateway, "oauth_exchange_failed", err.Error(), "api_error", nil)
return
}

email, name := jwtEmailName(tokens.IDToken)

s.mu.Lock()
defer s.mu.Unlock()
channel = s.findChannel(c.Param("id"))
if channel == nil {
c.JSON(http.StatusNotFound, gin.H{"error": gin.H{"message": "Channel not found"}})
return
}
s.ensureAntigravityChannelLocked(channel)

protectedAccess, err := s.protectSecret(tokens.AccessToken)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": gin.H{"message": "Failed to protect access token"}})
return
}
protectedRefresh := ""
if strings.TrimSpace(tokens.RefreshToken) != "" {
if protectedRefresh, err = s.protectSecret(tokens.RefreshToken); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": gin.H{"message": "Failed to protect refresh token"}})
return
}
}
protectedID := ""
if strings.TrimSpace(tokens.IDToken) != "" {
if protectedID, err = s.protectSecret(tokens.IDToken); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": gin.H{"message": "Failed to protect id token"}})
return
}
}
stored := OpenAIAccount{
ID: newID("oaiacc"),
Name: firstNonEmptyString(name, email),
Email: email,
AccessToken: protectedAccess,
RefreshToken: protectedRefresh,
IDToken: protectedID,
ExpiresAt: tokens.ExpiresAt,
LastRefresh: now(),
Source: "antigravity",
ImportedAt: time.Now().UTC().Format(time.RFC3339),
Status: "unchecked",
}
channel.OpenAIAccounts = append(channel.OpenAIAccounts, stored)
s.saveStateLocked()

c.JSON(http.StatusCreated, gin.H{
"account": publicOpenAIAccount(stored),
"channel": publicChannel(*channel),
})
}

// exchangeOpenAIOAuthCode exchanges an authorization code + PKCE verifier for
// ChatGPT tokens.
func (s *Server) exchangeOpenAIOAuthCode(code, verifier string) (OpenAIRefreshResult, error) {
Expand Down
43 changes: 0 additions & 43 deletions dist/assets/index-B276P45A.js

This file was deleted.

43 changes: 43 additions & 0 deletions dist/assets/index-BBl9ztAh.js

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion dist/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<title>CAPI</title>
<script type="module" crossorigin src="/assets/index-B276P45A.js"></script>
<script type="module" crossorigin src="/assets/index-BBl9ztAh.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-699x73P2.css">
</head>
<body>
Expand Down
Loading
Loading