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
2 changes: 1 addition & 1 deletion internal/cloud/opencsg.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ import (

const (
DefaultBaseURL = "https://ai.space.opencsg.com"
DefaultLoginURL = "https://iam.opencsg.com/login/oauth/authorize?client_id=d623c957e69976c8a7a8&response_type=code&redirect_uri=https://hub.opencsg.com/api/v1/callback/casdoor&scope=read&state=casdoor"
DefaultLoginURL = "https://iam.opencsg.com/login/oauth/authorize?client_id=d623c957e69976c8a7a8&response_type=code&redirect_uri=https://hub.opencsg.com/api/v1/callback/casdoor&scope=read&state=lite"
DefaultAccessTokenURL = "https://opencsg.com/settings/access-token"
defaultCacheTTL = 5 * time.Minute
cloudModelListPage = "1"
Expand Down
2 changes: 2 additions & 0 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ const (
DefaultListenAddr = ":11435"
DefaultDesktopAPIAddr = "127.0.0.1:11436"
DefaultDesktopAPIBindAddr = "0.0.0.0:11436"
DefaultAuthCallbackAddr = "127.0.0.1:11437"
DefaultCloudProviderName = "csghub"
DefaultMarketplaceSource = "opencsg"
DefaultHuggingFaceEndpoint = "https://huggingface.co"
Expand Down Expand Up @@ -88,6 +89,7 @@ type Config struct {
DesktopAPIAddr string `json:"-"`
DesktopAPIBindAddr string `json:"-"`
DesktopAPIBoundAddr string `json:"-"`
AuthCallbackAddr string `json:"-"`
}

func (c *Config) EffectiveListenAddr() string {
Expand Down
46 changes: 43 additions & 3 deletions internal/server/handlers_cloud.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ package server
import (
"context"
"encoding/json"
"io"
"log"
"net/http"
"strings"

Expand Down Expand Up @@ -59,16 +61,54 @@ func (s *Server) handleCloudAuthTokenSave(w http.ResponseWriter, r *http.Request
return
}

s.cfg.Token = token
if err := config.Save(s.cfg); err != nil {
if err := s.saveCloudAccessToken(token); err != nil {
writeError(w, http.StatusInternalServerError, "saving token: "+err.Error())
return
}

writeJSON(w, http.StatusOK, s.cloudAuthStatus(r.Context()))
}

func (s *Server) saveCloudAccessToken(token string) error {
token = strings.TrimSpace(token)
s.cfg.Token = token
if err := config.Save(s.cfg); err != nil {
return err
}
if s.cloud != nil {
s.cloud.SetAccessToken(token)
}
return nil
}

// handleCloudAuthCallback is the browser redirect target for the "lite" SSO
// flow. The user service redirects here after a successful web login with the
// access token in the query string; we persist it and return a small page that
// closes itself so the waiting web UI can pick up the new login state.
func (s *Server) handleCloudAuthCallback(w http.ResponseWriter, r *http.Request) {
token := strings.TrimSpace(r.URL.Query().Get("token"))
if token == "" {
writeCloudAuthCallbackPage(w, false, "missing_token")
return
}
if err := s.saveCloudAccessToken(token); err != nil {
log.Printf("cloud auth callback: saving token: %v", err)
writeCloudAuthCallbackPage(w, false, "save_failed")
return
}
writeCloudAuthCallbackPage(w, true, "")
}

writeJSON(w, http.StatusOK, s.cloudAuthStatus(r.Context()))
func writeCloudAuthCallbackPage(w http.ResponseWriter, success bool, errCode string) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("Cache-Control", "no-store")
w.Header().Set("Referrer-Policy", "no-referrer")
w.WriteHeader(http.StatusOK)
if success {
_, _ = io.WriteString(w, `<!doctype html><html><head><meta charset="utf-8"><title>Login complete</title></head><body style="font-family:system-ui;display:grid;place-items:center;height:100vh;margin:0"><p>Login complete. You can close this window and return to csglite.</p></body></html>`)
return
}
_, _ = io.WriteString(w, `<!doctype html><html><head><meta charset="utf-8"><title>Login failed</title></head><body style="font-family:system-ui;display:grid;place-items:center;height:100vh;margin:0"><p>Login failed (`+errCode+`). Please try again.</p></body></html>`)
}

func (s *Server) handleCloudAuthTokenDelete(w http.ResponseWriter, r *http.Request) {
Expand Down
43 changes: 43 additions & 0 deletions internal/server/handlers_cloud_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -224,6 +224,49 @@ func TestHandleCloudAuthTokenSaveAndDelete(t *testing.T) {
}
}

func TestHandleCloudAuthCallback(t *testing.T) {
config.Reset()
t.Cleanup(config.Reset)

home := t.TempDir()
t.Setenv("HOME", home)
t.Setenv("USERPROFILE", home)

s := newTestServer(t)

saveReq := httptest.NewRequest(http.MethodGet, "/api/cloud/auth/callback?token=%20test-token%20", nil)
w := httptest.NewRecorder()
s.handleCloudAuthCallback(w, saveReq)

if w.Code != http.StatusOK {
t.Fatalf("status = %d, want %d", w.Code, http.StatusOK)
}
if ct := w.Header().Get("Content-Type"); !strings.HasPrefix(ct, "text/html") {
t.Fatalf("Content-Type = %q, want text/html", ct)
}
if !strings.Contains(w.Body.String(), "Login complete") {
t.Fatalf("body = %q, want success page", w.Body.String())
}
if s.cfg.Token != "test-token" {
t.Fatalf("saved token = %q, want %q", s.cfg.Token, "test-token")
}
}

func TestHandleCloudAuthCallbackMissingToken(t *testing.T) {
s := newTestServer(t)

req := httptest.NewRequest(http.MethodGet, "/api/cloud/auth/callback", nil)
w := httptest.NewRecorder()
s.handleCloudAuthCallback(w, req)

if w.Code != http.StatusOK {
t.Fatalf("status = %d, want %d", w.Code, http.StatusOK)
}
if !strings.Contains(w.Body.String(), "Login failed") || !strings.Contains(w.Body.String(), "missing_token") {
t.Fatalf("body = %q, want failure page with missing_token", w.Body.String())
}
}

func TestHandleCloudAPIKeySaveAndDelete(t *testing.T) {
config.Reset()
t.Cleanup(config.Reset)
Expand Down
6 changes: 6 additions & 0 deletions internal/server/routes.go
Original file line number Diff line number Diff line change
Expand Up @@ -217,6 +217,12 @@ func (s *Server) externalAPIRoutes() http.Handler {
))
}

func (s *Server) authCallbackRoutes() http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("GET /api/cloud/auth/callback", s.handleCloudAuthCallback)
return mux
}

func desktopExternalAPIMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("Origin") != "" {
Expand Down
47 changes: 42 additions & 5 deletions internal/server/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -160,10 +160,11 @@ type Server struct {
appManager *apps.Manager
sourceSwitches *apps.SourceSwitchManager
appShells *aiAppShellManager
cloud *cloud.Service
http *http.Server
externalHTTP *http.Server
logBuf *LogBuffer
cloud *cloud.Service
http *http.Server
externalHTTP *http.Server
authCallbackHTTP *http.Server
logBuf *LogBuffer

mu sync.RWMutex
engines map[string]*managedEngine
Expand Down Expand Up @@ -350,6 +351,16 @@ func New(cfg *config.Config, version string) *Server {
return s
}

// authCallbackAddr returns the loopback address dedicated to receiving the
// "lite" SSO redirect, so the browser can hand the access token back to this
// process on a stable port regardless of the main listener address.
func (s *Server) authCallbackAddr() string {
if s != nil && strings.TrimSpace(s.cfg.AuthCallbackAddr) != "" {
return strings.TrimSpace(s.cfg.AuthCallbackAddr)
}
return config.DefaultAuthCallbackAddr
}

func resolveCloudURL(cfg *config.Config) string {
if u := strings.TrimSpace(cfg.AIGatewayURL); u != "" {
return u
Expand Down Expand Up @@ -386,6 +397,19 @@ func (s *Server) Run(ctx context.Context) error {
s.cfg.DesktopAPIBoundAddr = externalListener.Addr().String()
}

authCallbackListener, err := net.Listen("tcp", s.authCallbackAddr())
if err != nil {
log.Printf("cloud auth callback listener unavailable on %s: %v", s.authCallbackAddr(), err)
} else {
defer authCallbackListener.Close()
s.authCallbackHTTP = &http.Server{
Handler: s.authCallbackRoutes(),
ReadHeaderTimeout: 30 * time.Second,
WriteTimeout: 0,
IdleTimeout: 120 * time.Second,
}
}

go s.startEvictor(ctx)
go s.refreshCloudModelsOnStartup(ctx)
curationCtx, cancelCuration := context.WithCancel(ctx)
Expand All @@ -405,7 +429,7 @@ func (s *Server) Run(ctx context.Context) error {
}()
}

errCh := make(chan error, 2)
errCh := make(chan error, 3)
if s.cfg.DesktopMode {
baseURL := "http://" + boundAddr
ready := desktopReady{
Expand Down Expand Up @@ -444,6 +468,14 @@ func (s *Server) Run(ctx context.Context) error {
}
}()
}
if s.authCallbackHTTP != nil {
go func() {
log.Printf(" Auth callback: http://%s", s.authCallbackAddr())
if err := s.authCallbackHTTP.Serve(authCallbackListener); err != nil && !errors.Is(err, http.ErrServerClosed) {
errCh <- err
}
}()
}

select {
case err := <-errCh:
Expand Down Expand Up @@ -480,6 +512,11 @@ func (s *Server) shutdownHTTPServers(ctx context.Context) error {
if s.externalHTTP != nil {
externalErr = s.externalHTTP.Shutdown(ctx)
}
if s.authCallbackHTTP != nil {
if err := s.authCallbackHTTP.Shutdown(ctx); err != nil && externalErr == nil {
externalErr = err
}
}
internalErr := s.http.Shutdown(ctx)
if internalErr != nil {
return internalErr
Expand Down
33 changes: 33 additions & 0 deletions internal/server/static/openapi/local-api.json
Original file line number Diff line number Diff line change
Expand Up @@ -4027,6 +4027,39 @@
}
}
},
"/api/cloud/auth/callback": {
"get": {
"tags": [
"CloudAuth"
],
"summary": "Complete the OpenCSG SSO login redirect",
"description": "Browser redirect target for the lite SSO flow. Persists the access token carried in the token query parameter and returns a confirmation page.",
"operationId": "completeCloudAuthCallback",
"parameters": [
{
"name": "token",
"in": "query",
"required": true,
"description": "OpenCSG access token issued to the lite client.",
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "HTML confirmation showing whether the token was saved.",
"content": {
"text/html": {
"schema": {
"type": "string"
}
}
}
}
}
}
},
"/api/cloud/auth/token": {
"post": {
"tags": [
Expand Down
33 changes: 33 additions & 0 deletions openapi/local-api.json
Original file line number Diff line number Diff line change
Expand Up @@ -4027,6 +4027,39 @@
}
}
},
"/api/cloud/auth/callback": {
"get": {
"tags": [
"CloudAuth"
],
"summary": "Complete the OpenCSG SSO login redirect",
"description": "Browser redirect target for the lite SSO flow. Persists the access token carried in the token query parameter and returns a confirmation page.",
"operationId": "completeCloudAuthCallback",
"parameters": [
{
"name": "token",
"in": "query",
"required": true,
"description": "OpenCSG access token issued to the lite client.",
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "HTML confirmation showing whether the token was saved.",
"content": {
"text/html": {
"schema": {
"type": "string"
}
}
}
}
}
}
},
"/api/cloud/auth/token": {
"post": {
"tags": [
Expand Down
18 changes: 0 additions & 18 deletions web/src/i18n.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1111,7 +1111,6 @@ const en: Record<string, string> = {
"settings.loggedOut": "Not logged in",
"settings.loggedOutDesc": "Sign in to OpenCSG and save an Access Token to use the built-in API Key for OpenCSG models.",
"settings.login": "Open Login",
"settings.openTokenPage": "Open Access Token Page",
"settings.tokenSaved": "Access Token saved",
"settings.tokenSavedDesc": "We couldn't load profile details right now. You can sign in again or log out.",
"settings.tokenInputHint": "Paste an OpenCSG Access Token here to sign in or replace the saved token.",
Expand Down Expand Up @@ -1572,12 +1571,6 @@ const en: Record<string, string> = {
"chat.cloudLoginTitle": "Login required for {0} models",
"chat.cloudLoginDesc": "{0} models use the configured AI Gateway. Sign in with an Access Token; csghub-lite will load your built-in API Key for model requests.",
"chat.cloudOpenLogin": "Open Login",
"chat.cloudOpenTokenPage": "Open Access Token Page",
"chat.cloudTokenLabel": "Access Token",
"chat.cloudTokenPlaceholder": "Paste your OpenCSG access token",
"chat.cloudTokenHint": "Create or copy an Access Token from your OpenCSG account settings, then paste it here.",
"chat.cloudSaveToken": "Save Token",
"chat.cloudSavingToken": "Saving...",
"chat.cloudCancel": "Not now",
"chat.cloudApiKeyTitle": "OpenCSG AI Gateway API Key",
"chat.cloudApiKeyDesc": "OpenCSG models use the OpenCSG AI Gateway. Enter your API Key here to enable cloud model requests from csghub-lite.",
Expand All @@ -1595,8 +1588,6 @@ const en: Record<string, string> = {
"chat.cloudLoginRequired": "Cloud models require a cloud login.",
"chat.cloudAuthRequired": "Cloud login required. Please sign in to {0} or save an API Key.",
"chat.cloudBuiltinAPIKeyFailed": "Failed to load the {0} built-in API Key. Please sign in again or save an API Key.",
"chat.cloudLoginExpired": "Cloud login expired or token is invalid. Please log in again and update your Access Token.",
"chat.cloudTokenEmpty": "Please paste an Access Token.",
"chat.copyModel": "Copy model name",
"chat.conversations": "Conversations",
"chat.conversationHistory": "Conversation History",
Expand Down Expand Up @@ -2791,7 +2782,6 @@ const zh: Record<string, string> = {
"settings.loggedOut": "未登录",
"settings.loggedOutDesc": "登录 OpenCSG 并保存 Access Token 后,可使用账号的 built-in API Key 调用 OpenCSG 模型。",
"settings.login": "打开登录",
"settings.openTokenPage": "打开 Access Token 页面",
"settings.tokenSaved": "已保存 Access Token",
"settings.tokenSavedDesc": "暂时无法加载账号详情。你可以重新登录或退出。",
"settings.tokenInputHint": "在这里粘贴 OpenCSG Access Token,用于登录或替换已保存的 Token。",
Expand Down Expand Up @@ -3252,12 +3242,6 @@ const zh: Record<string, string> = {
"chat.cloudLoginTitle": "使用 {0} 模型需要登录",
"chat.cloudLoginDesc": "{0} 模型通过当前配置的 AI Gateway 调用。请用 Access Token 登录,csghub-lite 会在调用模型时获取你的 built-in API Key。",
"chat.cloudOpenLogin": "打开登录",
"chat.cloudOpenTokenPage": "打开 Access Token 页面",
"chat.cloudTokenLabel": "Access Token",
"chat.cloudTokenPlaceholder": "请粘贴你的 OpenCSG Access Token",
"chat.cloudTokenHint": "可在 OpenCSG 账号设置中创建或复制 Access Token,然后粘贴到这里。",
"chat.cloudSaveToken": "保存 Token",
"chat.cloudSavingToken": "保存中...",
"chat.cloudCancel": "稍后再说",
"chat.cloudApiKeyTitle": "OpenCSG AI Gateway API Key",
"chat.cloudApiKeyDesc": "OpenCSG 模型通过 OpenCSG AI Gateway 调用。请在这里填写 API Key,csghub-lite 会用它发起云端模型请求。",
Expand All @@ -3275,8 +3259,6 @@ const zh: Record<string, string> = {
"chat.cloudLoginRequired": "使用云端模型需要先登录云端服务。",
"chat.cloudAuthRequired": "需要登录云端服务。请登录 {0} 或保存 API Key。",
"chat.cloudBuiltinAPIKeyFailed": "无法加载 {0} built-in API Key。请重新登录或保存 API Key。",
"chat.cloudLoginExpired": "云端登录已过期或 Token 无效,请重新登录并更新 Access Token。",
"chat.cloudTokenEmpty": "请先粘贴 Access Token。",
"chat.conversations": "对话记录",
"chat.conversationHistory": "对话记录",
"chat.noConversations": "暂无对话记录",
Expand Down
Loading
Loading