fix: use cookie-based auth for login — resolves Windows 401

Root cause: GitLink login API returns session cookies (autologin_trustie),
not API access_tokens. The old code tried to use the cookie value as an
access_token query parameter, which GitLink rejects.

Fix: store auth cookies with "cookie:" prefix. Transport detects the
prefix and sends credentials as Cookie header instead of access_token
param. Private tokens (from --token mode) continue to use access_token
param — fully backward compatible.

Verified: login → store cookie → GetCurrentUser → repo list all pass.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
wbtiger 2026-04-08 13:01:55 +08:00
parent a27122ab76
commit 95fdee5cd8
3 changed files with 42 additions and 46 deletions

View File

@ -23,7 +23,7 @@ type LoginResult struct {
Message string `json:"message"`
}
// Login authenticates with username/password and stores the token.
// Login authenticates with username/password and stores the session cookie.
func Login(username, password string) (*LoginResult, error) {
cfg, err := config.Load()
if err != nil {
@ -68,62 +68,44 @@ func Login(username, password string) (*LoginResult, error) {
return nil, fmt.Errorf("%s", result.Message)
}
// Collect all candidate tokens
var candidates []string
// Source 1: autologin cookie (from final response and cookie jar)
// Collect auth cookies from response (GitLink uses autologin_trustie for session persistence)
var authCookies []string
for _, cookie := range resp.Cookies() {
if strings.Contains(strings.ToLower(cookie.Name), "autologin") {
candidates = append(candidates, cookie.Value)
break
if cookie.Name == "autologin_trustie" || cookie.Name == "_educoder_session" {
authCookies = append(authCookies, cookie.Name+"="+cookie.Value)
}
}
if len(candidates) == 0 {
// Also check the cookie jar (captures cookies from redirect hops)
// Also check cookie jar (captures cookies from redirect hops)
if len(authCookies) == 0 {
if u, err := url.Parse(loginURL); err == nil {
for _, cookie := range jar.Cookies(u) {
if strings.Contains(strings.ToLower(cookie.Name), "autologin") {
candidates = append(candidates, cookie.Value)
break
if cookie.Name == "autologin_trustie" || cookie.Name == "_educoder_session" {
authCookies = append(authCookies, cookie.Name+"="+cookie.Value)
}
}
}
}
// Source 2: token from response body
if result.Token != "" {
candidates = append(candidates, result.Token)
if len(authCookies) == 0 {
return nil, fmt.Errorf("login succeeded but no auth cookies received")
}
if len(candidates) == 0 {
return nil, fmt.Errorf("login succeeded but no access token received")
// Store as "cookie:<name>=<value>; <name>=<value>" format
// Transport will send these as Cookie header
tokenValue := "cookie:" + strings.Join(authCookies, "; ")
if err := StoreToken(tokenValue); err != nil {
return nil, fmt.Errorf("failed to store credentials: %w", err)
}
// Try each candidate token: store it, then verify with /users/me
var lastErr error
for _, token := range candidates {
if err := StoreToken(token); err != nil {
lastErr = fmt.Errorf("failed to store token: %w", err)
continue
}
// Verify the token actually works
if _, err := GetCurrentUser(); err == nil {
// Verify the stored token matches (catches keyring store/load mismatch)
stored, loadErr := LoadToken()
if loadErr != nil || stored != token {
// Keyring round-trip failed, force file-based storage
if writeErr := storeTokenFile(token); writeErr != nil {
lastErr = fmt.Errorf("failed to store token to file: %w", writeErr)
continue
}
}
return &result, nil
} else {
lastErr = err
}
// Verify the stored credentials actually work
if _, verifyErr := GetCurrentUser(); verifyErr != nil {
// Clean up the bad token
_ = DeleteToken()
return nil, fmt.Errorf("login failed: credentials not accepted by API (%v)", verifyErr)
}
return nil, fmt.Errorf("login succeeded but token verification failed (%v).\n\nPlease use private token instead:\n 1. Visit https://www.gitlink.org.cn/tokens → Create a new token\n 2. Run: gitlink-cli auth login --token\n 3. Paste your private token", lastErr)
return &result, nil
}
// GetCurrentUser fetches the authenticated user info.

View File

@ -2,9 +2,10 @@ package auth
import (
"net/http"
"strings"
)
// Transport wraps an http.RoundTripper and injects the autologin token.
// Transport wraps an http.RoundTripper and injects authentication.
type Transport struct {
Base http.RoundTripper
}
@ -12,9 +13,22 @@ type Transport struct {
func (t *Transport) RoundTrip(req *http.Request) (*http.Response, error) {
token, err := LoadToken()
if err == nil && token != "" {
q := req.URL.Query()
q.Set("access_token", token)
req.URL.RawQuery = q.Encode()
if strings.HasPrefix(token, "cookie:") {
// Cookie-based auth: token stored as "cookie:<name>=<value>"
cookiePart := strings.TrimPrefix(token, "cookie:")
// Append to existing cookies
existing := req.Header.Get("Cookie")
if existing != "" {
req.Header.Set("Cookie", existing+"; "+cookiePart)
} else {
req.Header.Set("Cookie", cookiePart)
}
} else {
// Private token: use access_token query parameter
q := req.URL.Query()
q.Set("access_token", token)
req.URL.RawQuery = q.Encode()
}
}
if req.Body != nil && req.Header.Get("Content-Type") == "" {
req.Header.Set("Content-Type", "application/json")

View File

@ -1,6 +1,6 @@
{
"name": "@gitlink-ai/cli",
"version": "0.1.10",
"version": "0.1.11",
"description": "GitLink 平台官方命令行工具 — 代码托管、协作开发和自动化",
"bin": {
"gitlink-cli": "bin/cli.js",