306 lines
9.5 KiB
Go
306 lines
9.5 KiB
Go
package provider
|
|
|
|
// Google accounts brought in from another tool's export rather than signed
|
|
// in again: Antigravity Cockpit (jlcodes99/vscode-antigravity-cockpit) and
|
|
// Antigravity Manager export [{email, refresh_token}], Antigravity Manager
|
|
// keeps each account as {email, token: {refresh_token, project_id, …}},
|
|
// CLIProxyAPI as {type: "antigravity", email, refresh_token, project_id, …},
|
|
// and Cockpit also takes bare refresh tokens, one a line. They all sign in
|
|
// with Antigravity's own OAuth client, the one magpie uses, so a refresh
|
|
// token from any of them works here. Each is checked the way a sign-in is
|
|
// finished: the token refreshed with Google, whose account it is asked,
|
|
// the Code Assist project found; only then is it kept, as a signed-in
|
|
// account is. Tokens are never logged or sent back.
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"net/url"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
// ImportedAccount is what became of one account in an import.
|
|
type ImportedAccount struct {
|
|
// User is the account's email, or "#n" (its place in the file) when
|
|
// the file doesn't say and Google wasn't asked or didn't answer.
|
|
User string `json:"user"`
|
|
// Status: "added", "updated" (in magpie already, now with this
|
|
// sign-in), "exists" (in magpie already with this very sign-in),
|
|
// "failed".
|
|
Status string `json:"status"`
|
|
Plan string `json:"plan,omitempty"`
|
|
Error string `json:"error,omitempty"`
|
|
}
|
|
|
|
// googleImport is one account read from an export.
|
|
type googleImport struct {
|
|
n int // its place in the file(s), from 1
|
|
email, refreshToken, project string
|
|
err string // why it can't be imported, found reading it
|
|
}
|
|
|
|
// maxGoogleImport caps the accounts one import takes.
|
|
const maxGoogleImport = 200
|
|
|
|
// parseGoogleImport reads the accounts in an export: a JSON array or
|
|
// object in any of the shapes above, or refresh tokens one a line. agent
|
|
// is the app they are for; an entry another app's sign-in is refused.
|
|
func parseGoogleImport(agent, data string) ([]googleImport, error) {
|
|
s := strings.TrimSpace(strings.TrimPrefix(data, "\xef\xbb\xbf")) // a BOM
|
|
if s == "" {
|
|
return nil, errors.New("nothing to import")
|
|
}
|
|
var out []googleImport
|
|
add := func(e googleImport) {
|
|
e.n = len(out) + 1
|
|
out = append(out, e)
|
|
}
|
|
if s[0] == '[' || s[0] == '{' {
|
|
var v any
|
|
if err := json.Unmarshal([]byte(s), &v); err != nil {
|
|
return nil, fmt.Errorf("not valid JSON: %v", err)
|
|
}
|
|
var walk func(v any)
|
|
walk = func(v any) {
|
|
switch x := v.(type) {
|
|
case []any:
|
|
for _, it := range x {
|
|
walk(it)
|
|
}
|
|
case map[string]any:
|
|
// Antigravity Manager's export response: {accounts: [...]}
|
|
if as, ok := x["accounts"].([]any); ok && jsonStr(x, "refresh_token", "refreshToken") == "" {
|
|
walk(as)
|
|
return
|
|
}
|
|
add(googleEntry(agent, x))
|
|
case string:
|
|
add(googleImport{refreshToken: strings.TrimSpace(x)})
|
|
default:
|
|
add(googleImport{err: "not an account"})
|
|
}
|
|
}
|
|
walk(v)
|
|
} else {
|
|
for _, line := range strings.Split(s, "\n") {
|
|
line = strings.TrimSpace(line)
|
|
if line == "" || strings.HasPrefix(line, "#") {
|
|
continue
|
|
}
|
|
if strings.ContainsAny(line, " \t") {
|
|
add(googleImport{err: "not a refresh token"})
|
|
continue
|
|
}
|
|
add(googleImport{refreshToken: line})
|
|
}
|
|
}
|
|
if len(out) == 0 {
|
|
return nil, errors.New("no accounts in it")
|
|
}
|
|
if len(out) > maxGoogleImport {
|
|
return nil, fmt.Errorf("%d accounts in it; at most %d at a time", len(out), maxGoogleImport)
|
|
}
|
|
// the same account twice is imported once
|
|
seenTok, seenUser := map[string]bool{}, map[string]bool{}
|
|
for i := range out {
|
|
e := &out[i]
|
|
if e.err != "" {
|
|
continue
|
|
}
|
|
if e.refreshToken == "" {
|
|
e.err = "no refresh_token in it"
|
|
continue
|
|
}
|
|
u := strings.ToLower(e.email)
|
|
if seenTok[e.refreshToken] || (u != "" && seenUser[u]) {
|
|
e.err = "in the file twice"
|
|
continue
|
|
}
|
|
seenTok[e.refreshToken] = true
|
|
if u != "" {
|
|
seenUser[u] = true
|
|
}
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// googleEntry reads one account object.
|
|
func googleEntry(agent string, x map[string]any) googleImport {
|
|
e := googleImport{
|
|
email: jsonStr(x, "email"),
|
|
refreshToken: jsonStr(x, "refresh_token", "refreshToken"),
|
|
project: jsonStr(x, "project_id", "projectId", "project"),
|
|
}
|
|
// Antigravity Manager's account file keeps the sign-in under token
|
|
if tok, ok := x["token"].(map[string]any); ok {
|
|
if e.refreshToken == "" {
|
|
e.refreshToken = jsonStr(tok, "refresh_token", "refreshToken")
|
|
}
|
|
if e.email == "" {
|
|
e.email = jsonStr(tok, "email")
|
|
}
|
|
if e.project == "" {
|
|
e.project = jsonStr(tok, "project_id", "projectId")
|
|
}
|
|
}
|
|
// CLIProxyAPI names the app its auth file is for
|
|
if typ := strings.ToLower(jsonStr(x, "type")); typ != "" && typ != agent {
|
|
app, _ := googleAppOf(agent)
|
|
e.err = fmt.Sprintf("a %s sign-in, not %s's", jsonStr(x, "type"), app.name)
|
|
}
|
|
return e
|
|
}
|
|
|
|
// jsonStr is the first of keys that x has as a non-empty string.
|
|
func jsonStr(x map[string]any, keys ...string) string {
|
|
for _, k := range keys {
|
|
if s, ok := x[k].(string); ok && strings.TrimSpace(s) != "" {
|
|
return strings.TrimSpace(s)
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// ImportGoogleAccounts brings in the accounts in exports from other tools
|
|
// (see above), each file's text one element of files, and says what
|
|
// became of each.
|
|
func ImportGoogleAccounts(ctx context.Context, agent string, files []string) ([]ImportedAccount, error) {
|
|
app, ok := googleAppOf(agent)
|
|
if !ok || agent != "antigravity" {
|
|
return nil, fmt.Errorf("accounts can't be imported for %s", agent)
|
|
}
|
|
var all []googleImport
|
|
for _, f := range files {
|
|
es, err := parseGoogleImport(agent, f)
|
|
if err != nil {
|
|
if len(files) == 1 {
|
|
return nil, err
|
|
}
|
|
continue // one file of several that holds none
|
|
}
|
|
all = append(all, es...)
|
|
}
|
|
if len(all) == 0 {
|
|
return nil, errors.New("no accounts in these files")
|
|
}
|
|
if len(all) > maxGoogleImport {
|
|
return nil, fmt.Errorf("%d accounts; at most %d at a time", len(all), maxGoogleImport)
|
|
}
|
|
// what magpie has already: refresh token and user of each account
|
|
haveTok, haveUser := map[string]string{}, map[string]string{}
|
|
loginsMu.Lock()
|
|
for _, l := range readLogins() {
|
|
if l.Agent != agent {
|
|
continue
|
|
}
|
|
var a googleAuth
|
|
_ = json.Unmarshal(l.Auth, &a)
|
|
haveUser[strings.ToLower(l.User)] = a.RefreshToken
|
|
if a.RefreshToken != "" {
|
|
haveTok[a.RefreshToken] = l.User
|
|
}
|
|
}
|
|
loginsMu.Unlock()
|
|
|
|
out := make([]ImportedAccount, len(all))
|
|
var wg sync.WaitGroup
|
|
sem := make(chan struct{}, 4)
|
|
for i, e := range all {
|
|
name := e.email
|
|
if name == "" {
|
|
name = fmt.Sprintf("#%d", i+1)
|
|
}
|
|
switch {
|
|
case e.err != "":
|
|
out[i] = ImportedAccount{User: name, Status: "failed", Error: e.err}
|
|
continue
|
|
case haveTok[e.refreshToken] != "":
|
|
out[i] = ImportedAccount{User: haveTok[e.refreshToken], Status: "exists"}
|
|
continue
|
|
}
|
|
wg.Add(1)
|
|
go func() {
|
|
defer wg.Done()
|
|
sem <- struct{}{}
|
|
defer func() { <-sem }()
|
|
c, cancel := context.WithTimeout(ctx, 45*time.Second)
|
|
defer cancel()
|
|
out[i] = importGoogleAccount(c, app, e, name, haveUser)
|
|
}()
|
|
}
|
|
wg.Wait()
|
|
for _, r := range out {
|
|
if r.Status == "added" || r.Status == "updated" {
|
|
// an import brings back an account removed from magpie, as
|
|
// signing in does
|
|
_ = ShowAccount(agent)
|
|
break
|
|
}
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// importGoogleAccount checks one account as a sign-in is finished and
|
|
// keeps it.
|
|
func importGoogleAccount(ctx context.Context, app googleApp, e googleImport, name string, haveUser map[string]string) ImportedAccount {
|
|
fail := func(msg string) ImportedAccount {
|
|
return ImportedAccount{User: name, Status: "failed", Error: msg}
|
|
}
|
|
form := url.Values{"grant_type": {"refresh_token"}, "refresh_token": {e.refreshToken},
|
|
"client_id": {app.clientID}, "client_secret": {app.clientSecret}}.Encode()
|
|
var tok struct {
|
|
AccessToken string `json:"access_token"`
|
|
ExpiresIn int64 `json:"expires_in"`
|
|
}
|
|
if err := postToken(ctx, googleTokenURL, "application/x-www-form-urlencoded", []byte(form), &tok); err != nil {
|
|
msg := err.Error()
|
|
if strings.Contains(msg, "(401)") || strings.Contains(strings.ToLower(msg), "unauthorized") {
|
|
msg += " — this sign-in was made for another app, not " + app.name
|
|
} else if strings.Contains(msg, "(400)") && !strings.Contains(msg, "revoked") {
|
|
// Google says only "Bad Request" for a token it doesn't know
|
|
msg += " — it has expired or been revoked, or isn't a refresh token"
|
|
}
|
|
return fail("Google didn't take the refresh token: " + msg)
|
|
}
|
|
if tok.AccessToken == "" {
|
|
return fail("Google sent back no token")
|
|
}
|
|
g := googleAccount{app: app, auth: googleAuth{AccessToken: tok.AccessToken, RefreshToken: e.refreshToken,
|
|
Expiry: time.Now().Add(time.Duration(tok.ExpiresIn) * time.Second).UnixMilli()}}
|
|
who, err := googleWho(ctx, tok.AccessToken)
|
|
switch {
|
|
case err == nil:
|
|
g.user = who
|
|
case e.email != "":
|
|
g.user = e.email // the file says, and the token works
|
|
default:
|
|
return fail(err.Error())
|
|
}
|
|
status := "added"
|
|
if old, ok := haveUser[strings.ToLower(g.user)]; ok {
|
|
if old == e.refreshToken {
|
|
return ImportedAccount{User: g.user, Status: "exists"}
|
|
}
|
|
status = "updated"
|
|
}
|
|
// its project and plan found as a sign-in finds them; the file's
|
|
// project is kept when Code Assist won't say
|
|
plan := ""
|
|
if p, err := g.project(ctx); err == nil {
|
|
g.auth.Project, plan = p.id, p.plan
|
|
} else if e.project != "" {
|
|
g.auth.Project = e.project
|
|
}
|
|
if err := addGoogleLogin(app.agent, g.user, plan, g.auth); err != nil {
|
|
return fail(err.Error())
|
|
}
|
|
googleState.Lock()
|
|
googleState.tokens[e.refreshToken] = g.auth
|
|
googleState.Unlock()
|
|
return ImportedAccount{User: g.user, Status: status, Plan: plan}
|
|
}
|