diff --git a/.github/workflows/pr-test.yml b/.github/workflows/pr-test.yml index db5a324d..3f1bcfa4 100644 --- a/.github/workflows/pr-test.yml +++ b/.github/workflows/pr-test.yml @@ -1704,22 +1704,42 @@ jobs: -p 8080:8080 -p 9100:9100 -p 2375:2375 -p 6080:6080 -p 6443:6443 \ -e TAI_K8S_UPSTREAM="tcp://${K3D_IP}:6443" \ yaoapp/tai:latest + + TAI_HTTP_READY=false for i in $(seq 1 30); do if curl -sf http://127.0.0.1:8080/healthz > /dev/null 2>&1; then echo "Tai HTTP is ready" + TAI_HTTP_READY=true break fi echo "Waiting for Tai HTTP... ($i)" sleep 1 done + if [ "$TAI_HTTP_READY" != "true" ]; then + echo "::error::Tai HTTP failed to become ready within 30s" + echo "--- Tai container logs ---" + docker logs tai 2>&1 || true + echo "--- Tai container status ---" + docker inspect tai --format='{{.State.Status}} exit={{.State.ExitCode}}' || true + exit 1 + fi + + TAI_GRPC_READY=false for i in $(seq 1 15); do if nc -z 127.0.0.1 9100 2>/dev/null; then echo "Tai gRPC is ready" + TAI_GRPC_READY=true break fi echo "Waiting for Tai gRPC... ($i)" sleep 1 done + if [ "$TAI_GRPC_READY" != "true" ]; then + echo "::error::Tai gRPC failed to become ready within 15s" + echo "--- Tai container logs ---" + docker logs tai 2>&1 || true + exit 1 + fi - name: Generate kubeconfig for Tai K8s proxy run: | diff --git a/.github/workflows/unit-test.yml b/.github/workflows/unit-test.yml index 9bdc380d..3e04605e 100644 --- a/.github/workflows/unit-test.yml +++ b/.github/workflows/unit-test.yml @@ -1258,22 +1258,42 @@ jobs: -p 8080:8080 -p 9100:9100 -p 2375:2375 -p 6080:6080 -p 6443:6443 \ -e TAI_K8S_UPSTREAM="tcp://${K3D_IP}:6443" \ yaoapp/tai:latest + + TAI_HTTP_READY=false for i in $(seq 1 30); do if curl -sf http://127.0.0.1:8080/healthz > /dev/null 2>&1; then echo "Tai HTTP is ready" + TAI_HTTP_READY=true break fi echo "Waiting for Tai HTTP... ($i)" sleep 1 done + if [ "$TAI_HTTP_READY" != "true" ]; then + echo "::error::Tai HTTP failed to become ready within 30s" + echo "--- Tai container logs ---" + docker logs tai 2>&1 || true + echo "--- Tai container status ---" + docker inspect tai --format='{{.State.Status}} exit={{.State.ExitCode}}' || true + exit 1 + fi + + TAI_GRPC_READY=false for i in $(seq 1 15); do if nc -z 127.0.0.1 9100 2>/dev/null; then echo "Tai gRPC is ready" + TAI_GRPC_READY=true break fi echo "Waiting for Tai gRPC... ($i)" sleep 1 done + if [ "$TAI_GRPC_READY" != "true" ]; then + echo "::error::Tai gRPC failed to become ready within 15s" + echo "--- Tai container logs ---" + docker logs tai 2>&1 || true + exit 1 + fi - name: Generate kubeconfig for Tai K8s proxy run: | diff --git a/cmd/credential.go b/cmd/credential.go new file mode 100644 index 00000000..9f8f3dc9 --- /dev/null +++ b/cmd/credential.go @@ -0,0 +1,118 @@ +package cmd + +import ( + "encoding/base64" + "encoding/json" + "fmt" + "os" + "path/filepath" + "time" +) + +// Credential represents the stored OAuth credential for gRPC mode. +type Credential struct { + Server string `json:"server"` + GRPCAddr string `json:"grpc_addr,omitempty"` + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token,omitempty"` + Scope string `json:"scope,omitempty"` + User string `json:"user,omitempty"` + ExpiresAt string `json:"expires_at,omitempty"` +} + +// Expired returns true if the credential has an expires_at in the past. +func (c *Credential) Expired() bool { + if c.ExpiresAt == "" { + return false + } + t, err := time.Parse(time.RFC3339, c.ExpiresAt) + if err != nil { + return false + } + return time.Now().After(t) +} + +func credentialPath() (string, error) { + home, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("cannot determine home directory: %w", err) + } + return filepath.Join(home, ".yao", "credentials"), nil +} + +// LoadCredential reads and decodes ~/.yao/credentials. Returns nil if the file +// does not exist. +func LoadCredential() (*Credential, error) { + path, err := credentialPath() + if err != nil { + return nil, err + } + raw, err := os.ReadFile(path) + if os.IsNotExist(err) { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("read credentials: %w", err) + } + + decoded, err := base64.StdEncoding.DecodeString(string(raw)) + if err != nil { + return nil, fmt.Errorf("decode credentials: %w", err) + } + + var cred Credential + if err := json.Unmarshal(decoded, &cred); err != nil { + return nil, fmt.Errorf("unmarshal credentials: %w", err) + } + return &cred, nil +} + +// LoadCredentialFrom reads and decodes a credential file from a custom path. +func LoadCredentialFrom(path string) (*Credential, error) { + raw, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("read credentials from %s: %w", path, err) + } + decoded, err := base64.StdEncoding.DecodeString(string(raw)) + if err != nil { + return nil, fmt.Errorf("decode credentials: %w", err) + } + var cred Credential + if err := json.Unmarshal(decoded, &cred); err != nil { + return nil, fmt.Errorf("unmarshal credentials: %w", err) + } + return &cred, nil +} + +// SaveCredential encodes and writes the credential to ~/.yao/credentials. +func SaveCredential(cred *Credential) error { + path, err := credentialPath() + if err != nil { + return err + } + dir := filepath.Dir(path) + if err := os.MkdirAll(dir, 0700); err != nil { + return fmt.Errorf("create directory %s: %w", dir, err) + } + data, err := json.Marshal(cred) + if err != nil { + return fmt.Errorf("marshal credentials: %w", err) + } + encoded := base64.StdEncoding.EncodeToString(data) + if err := os.WriteFile(path, []byte(encoded), 0600); err != nil { + return fmt.Errorf("write credentials: %w", err) + } + return nil +} + +// RemoveCredential deletes ~/.yao/credentials. +func RemoveCredential() error { + path, err := credentialPath() + if err != nil { + return err + } + if err := os.Remove(path); err != nil && !os.IsNotExist(err) { + return fmt.Errorf("remove credentials: %w", err) + } + return nil +} diff --git a/cmd/login.go b/cmd/login.go new file mode 100644 index 00000000..b2221e6d --- /dev/null +++ b/cmd/login.go @@ -0,0 +1,342 @@ +package cmd + +import ( + "encoding/base64" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "os" + "strings" + "time" + + "github.com/fatih/color" + "github.com/spf13/cobra" + "github.com/yaoapp/yao/engine" +) + +var loginServer string + +var loginCmd = &cobra.Command{ + Use: "login", + Short: L("Login to remote Yao server"), + Long: L("Login to remote Yao server using device authorization flow"), + Run: func(cmd *cobra.Command, args []string) { + if loginServer == "" { + color.Red(L("Missing --server flag\n")) + fmt.Println(" yao login --server https://yaoagents.com") + os.Exit(1) + } + + serverURL := strings.TrimRight(loginServer, "/") + + // 1. Discover OAuth endpoints via well-known metadata + endpoints, err := discoverEndpoints(serverURL) + if err != nil { + color.Red(" %s %s\n", L("Server discovery failed:"), err) + os.Exit(1) + } + + // 2. Compute deterministic client_id from machine fingerprint + machine, err := engine.GetMachineID() + if err != nil { + color.Red("Failed to compute machine ID: %s\n", err) + os.Exit(1) + } + clientID := machine.ID + + // 3. Register the client (idempotent for same client_id) + if endpoints.RegistrationEndpoint != "" { + if err := registerClient(endpoints.RegistrationEndpoint, clientID); err != nil { + color.Red("Client registration failed: %s\n", err) + os.Exit(1) + } + } + + // 4. Start device authorization + deviceResp, err := requestDeviceAuthorization(endpoints.DeviceAuthorizationEndpoint, clientID) + if err != nil { + color.Red("Device authorization failed: %s\n", err) + os.Exit(1) + } + + // 5. Display the code to the user + dashboard := endpoints.Dashboard + if dashboard == "" { + dashboard = "/admin" + } + verifyURI := strings.TrimRight(serverURL, "/") + dashboard + "/auth/device" + verifyURIComplete := verifyURI + "?user_code=" + deviceResp.UserCode + + fmt.Println() + color.White(" %s %s\n", + L("Open:"), + color.CyanString(verifyURIComplete)) + fmt.Println() + color.White(" %s %s\n", + L("Or visit:"), + color.CyanString(verifyURI)) + color.White(" %s %s\n", + L("Enter code:"), + color.YellowString(deviceResp.UserCode)) + fmt.Println() + + // 6. Poll for token + interval := deviceResp.Interval + if interval < 5 { + interval = 5 + } + + color.White(" %s", L("Waiting for authorization...")) + tokenResp, err := pollForToken(endpoints.TokenEndpoint, clientID, deviceResp.DeviceCode, interval, deviceResp.ExpiresIn) + if err != nil { + fmt.Println() + color.Red("\n %s %s\n", L("Login failed:"), err) + os.Exit(1) + } + + // 6. Save credential + expiresAt := "" + if tokenResp.ExpiresIn > 0 { + expiresAt = time.Now().Add(time.Duration(tokenResp.ExpiresIn) * time.Second).UTC().Format(time.RFC3339) + } + + cred := &Credential{ + Server: serverURL, + GRPCAddr: endpoints.GRPCAddr, + AccessToken: tokenResp.AccessToken, + RefreshToken: tokenResp.RefreshToken, + Scope: tokenResp.Scope, + User: parseJWTSubject(tokenResp.AccessToken), + ExpiresAt: expiresAt, + } + + if err := SaveCredential(cred); err != nil { + color.Red("\n Failed to save credentials: %s\n", err) + os.Exit(1) + } + + fmt.Print("\033[2J\033[H") + color.Green(" ✓ %s\n", L("Login successful")) + color.White(" %s %s\n", L("Server:"), serverURL) + if cred.GRPCAddr != "" { + color.White(" %s %s\n", L("gRPC:"), cred.GRPCAddr) + } + if cred.User != "" { + color.White(" %s %s\n", L("User:"), cred.User) + } + if cred.ExpiresAt != "" { + color.White(" %s %s\n", L("Expires:"), cred.ExpiresAt) + } + fmt.Println() + }, +} + +func init() { + loginCmd.PersistentFlags().StringVar(&loginServer, "server", "", L("Remote Yao server URL")) +} + +// --- types --- + +type oauthEndpoints struct { + RegistrationEndpoint string `json:"registration_endpoint"` + DeviceAuthorizationEndpoint string `json:"device_authorization_endpoint"` + TokenEndpoint string `json:"token_endpoint"` + RevocationEndpoint string `json:"revocation_endpoint"` + Dashboard string `json:"-"` + GRPCAddr string `json:"-"` +} + +type deviceAuthResponse struct { + DeviceCode string `json:"device_code"` + UserCode string `json:"user_code"` + VerificationURI string `json:"verification_uri"` + VerificationURIComplete string `json:"verification_uri_complete"` + ExpiresIn int `json:"expires_in"` + Interval int `json:"interval"` +} + +type tokenResponse struct { + AccessToken string `json:"access_token"` + TokenType string `json:"token_type"` + ExpiresIn int `json:"expires_in"` + RefreshToken string `json:"refresh_token"` + Scope string `json:"scope"` +} + +type oauthError struct { + Error string `json:"error"` + ErrorDescription string `json:"error_description"` +} + +// --- HTTP helpers --- + +// discoverEndpoints fetches OAuth endpoint URLs from /.well-known/yao, +// using the openapi base prefix to construct correct API paths. +func discoverEndpoints(serverURL string) (*oauthEndpoints, error) { + return discoverFromYaoMetadata(serverURL) +} + +type yaoMetadataResponse struct { + OpenAPI string `json:"openapi"` + Dashboard string `json:"dashboard"` + GRPC string `json:"grpc"` +} + +func discoverFromYaoMetadata(serverURL string) (*oauthEndpoints, error) { + resp, err := http.Get(serverURL + "/.well-known/yao") + if err != nil { + return nil, fmt.Errorf("network error: %w", err) + } + defer resp.Body.Close() + + body, _ := io.ReadAll(resp.Body) + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("/.well-known/yao returned %d", resp.StatusCode) + } + + var meta yaoMetadataResponse + if err := json.Unmarshal(body, &meta); err != nil { + return nil, fmt.Errorf("invalid /.well-known/yao response: %w", err) + } + + base := strings.TrimRight(serverURL, "/") + meta.OpenAPI + + return &oauthEndpoints{ + RegistrationEndpoint: base + "/oauth/register", + DeviceAuthorizationEndpoint: base + "/oauth/device_authorization", + TokenEndpoint: base + "/oauth/token", + RevocationEndpoint: base + "/oauth/revoke", + Dashboard: meta.Dashboard, + GRPCAddr: meta.GRPC, + }, nil +} + +func registerClient(endpoint, clientID string) error { + body := fmt.Sprintf( + `{"client_id":"%s","client_name":"yao-cli","grant_types":["urn:ietf:params:oauth:grant-type:device_code"],"token_endpoint_auth_method":"none","redirect_uris":["http://localhost"]}`, + clientID, + ) + resp, err := http.Post(endpoint, "application/json", strings.NewReader(body)) + if err != nil { + return fmt.Errorf("network error: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode == http.StatusOK || resp.StatusCode == http.StatusCreated { + return nil + } + + respBody, _ := io.ReadAll(resp.Body) + var oerr oauthError + if json.Unmarshal(respBody, &oerr) == nil && oerr.Error == "invalid_client_metadata" { + return nil // client already registered, idempotent + } + return fmt.Errorf("registration returned %d: %s", resp.StatusCode, string(respBody)) +} + +func requestDeviceAuthorization(endpoint, clientID string) (*deviceAuthResponse, error) { + data := url.Values{ + "client_id": {clientID}, + "scope": {"grpc:run grpc:stream grpc:shell grpc:mcp grpc:llm grpc:agent"}, + } + resp, err := http.PostForm(endpoint, data) + if err != nil { + return nil, fmt.Errorf("network error: %w", err) + } + defer resp.Body.Close() + + respBody, _ := io.ReadAll(resp.Body) + if resp.StatusCode != http.StatusOK { + var oerr oauthError + json.Unmarshal(respBody, &oerr) + if oerr.ErrorDescription != "" { + return nil, fmt.Errorf("%s", oerr.ErrorDescription) + } + return nil, fmt.Errorf("server returned %d: %s", resp.StatusCode, string(respBody)) + } + + var result deviceAuthResponse + if err := json.Unmarshal(respBody, &result); err != nil { + return nil, fmt.Errorf("invalid response: %w", err) + } + return &result, nil +} + +func pollForToken(endpoint, clientID, deviceCode string, interval, expiresIn int) (*tokenResponse, error) { + deadline := time.Now().Add(time.Duration(expiresIn) * time.Second) + ticker := time.NewTicker(time.Duration(interval) * time.Second) + defer ticker.Stop() + + for range ticker.C { + if time.Now().After(deadline) { + return nil, fmt.Errorf("device code expired") + } + + data := url.Values{ + "grant_type": {"urn:ietf:params:oauth:grant-type:device_code"}, + "client_id": {clientID}, + "device_code": {deviceCode}, + } + + resp, err := http.PostForm(endpoint, data) + if err != nil { + continue + } + + respBody, _ := io.ReadAll(resp.Body) + resp.Body.Close() + + if resp.StatusCode == http.StatusOK { + var tok tokenResponse + if err := json.Unmarshal(respBody, &tok); err != nil { + return nil, fmt.Errorf("invalid token response: %w", err) + } + return &tok, nil + } + + var oerr oauthError + json.Unmarshal(respBody, &oerr) + switch oerr.Error { + case "authorization_pending": + fmt.Print(".") + continue + case "slow_down": + interval += 5 + ticker.Reset(time.Duration(interval) * time.Second) + continue + case "expired_token": + return nil, fmt.Errorf("device code expired") + case "access_denied": + return nil, fmt.Errorf("authorization denied by user") + default: + desc := oerr.ErrorDescription + if desc == "" { + desc = oerr.Error + } + return nil, fmt.Errorf("%s", desc) + } + } + return nil, fmt.Errorf("device code expired") +} + +// parseJWTSubject extracts the "sub" claim from a JWT access token +// without verifying the signature (display-only). +func parseJWTSubject(token string) string { + parts := strings.Split(token, ".") + if len(parts) != 3 { + return "" + } + payload, err := base64.RawURLEncoding.DecodeString(parts[1]) + if err != nil { + return "" + } + var claims struct { + Sub string `json:"sub"` + } + if json.Unmarshal(payload, &claims) != nil { + return "" + } + return claims.Sub +} diff --git a/cmd/logout.go b/cmd/logout.go new file mode 100644 index 00000000..6c56ce2e --- /dev/null +++ b/cmd/logout.go @@ -0,0 +1,80 @@ +package cmd + +import ( + "net/http" + "net/url" + "os" + "strings" + + "github.com/fatih/color" + "github.com/spf13/cobra" +) + +var logoutCmd = &cobra.Command{ + Use: "logout", + Short: L("Logout from remote Yao server"), + Long: L("Revoke token and remove stored credentials"), + Run: func(cmd *cobra.Command, args []string) { + cred, err := LoadCredential() + if err != nil { + color.Red(" %s %s\n", L("Failed to read credentials:"), err) + os.Exit(1) + } + if cred == nil { + color.Yellow(" %s\n", L("Not logged in")) + return + } + + // Best-effort token revocation via discovery + if cred.AccessToken != "" && cred.Server != "" { + if ep, err := discoverEndpoints(cred.Server); err == nil && ep.RevocationEndpoint != "" { + revokeToken(ep.RevocationEndpoint, cred.AccessToken) + } + } + + if err := RemoveCredential(); err != nil { + color.Red(" %s %s\n", L("Failed to remove credentials:"), err) + os.Exit(1) + } + + color.Green(" ✓ %s\n", L("Logged out")) + if cred.Server != "" { + color.White(" %s %s\n", L("Server:"), cred.Server) + } + }, +} + +func revokeToken(endpoint, token string) { + data := url.Values{"token": {token}} + req, err := http.NewRequest("POST", endpoint, strings.NewReader(data.Encode())) + if err != nil { + return + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + http.DefaultClient.Do(req) +} + +func init() { + // Add i18n entries + langs["Login to remote Yao server"] = "登录远程 Yao 服务器" + langs["Login to remote Yao server using device authorization flow"] = "使用设备授权流程登录远程 Yao 服务器" + langs["Remote Yao server URL"] = "远程 Yao 服务器地址" + langs["Logout from remote Yao server"] = "登出远程 Yao 服务器" + langs["Revoke token and remove stored credentials"] = "撤销令牌并移除存储的凭证" + langs["Missing --server flag"] = "缺少 --server 参数" + langs["Open:"] = "打开:" + langs["Or visit:"] = "或访问:" + langs["Enter code:"] = "输入设备码:" + langs["Waiting for authorization..."] = "等待授权..." + langs["Login failed:"] = "登录失败:" + langs["Login successful"] = "登录成功" + langs["Server:"] = "服务器:" + langs["Scope:"] = "授权范围:" + langs["Failed to read credentials:"] = "读取凭证失败:" + langs["Not logged in"] = "未登录" + langs["Failed to remove credentials:"] = "移除凭证失败:" + langs["Logged out"] = "已登出" + langs["Path to credentials file"] = "凭证文件路径" + langs["Failed to load credentials:"] = "加载凭证失败:" + langs["Server discovery failed:"] = "服务发现失败:" +} diff --git a/cmd/root.go b/cmd/root.go index 1a228402..7f32b1f4 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -189,6 +189,8 @@ func init() { inspectCmd, startCmd, runCmd, + loginCmd, + logoutCmd, // getCmd, // dumpCmd, // restoreCmd, diff --git a/cmd/run.go b/cmd/run.go index aa840ae8..f6dcca3b 100644 --- a/cmd/run.go +++ b/cmd/run.go @@ -18,173 +18,271 @@ import ( "github.com/yaoapp/yao/engine" ischedule "github.com/yaoapp/yao/schedule" "github.com/yaoapp/yao/share" + taigrpc "github.com/yaoapp/yao/tai/grpc" itask "github.com/yaoapp/yao/task" ) var runSilent = false +var runAuthPath string var runCmd = &cobra.Command{ Use: "run", Short: L("Execute process"), Long: L("Execute process"), Run: func(cmd *cobra.Command, args []string) { - defer share.SessionStop() - defer plugin.KillAll() - defer func() { - err := exception.Catch(recover()) - if err != nil { - if !runSilent { - color.Red(L("Fatal: %s\n"), err.Error()) - return - } - fmt.Printf("%s\n", err.Error()) - } - }() + // Resolve credential: --auth flag > ~/.yao/credentials > nil (local mode) + cred := resolveCredential() - // Auto-detect app root if not specified - if appPath == "" { - cwd, err := os.Getwd() - if err == nil { - if root, err := findAppRootFromPath(cwd); err == nil { - appPath = root - } - } - } - - Boot() - - // Set Runtime Mode - config.Conf.Runtime.Mode = "standard" - - cfg := config.Conf - cfg.Session.IsCLI = true - if len(args) < 1 { - if !runSilent { - color.Red(L("Not enough arguments\n")) - color.White(share.BUILDNAME + " help\n") - return - } - fmt.Print(L("Not enough arguments\n")) + if cred != nil { + runGRPC(cred, args) return } - loadWarnings, err := engine.Load(cfg, engine.LoadOption{Action: "run"}) - if err != nil { - if !runSilent { - color.Red(L("Engine: %s\n"), err.Error()) - return - } - - fmt.Printf("%s\n", err.Error()) - return - } - - name := args[0] - if !runSilent { - color.Green(L("Run: %s\n"), name) - } - - pargs := []interface{}{} - for i, arg := range args { - if i == 0 { - continue - } - - // Parse the arguments - if strings.HasPrefix(arg, "::") { - arg := strings.TrimPrefix(arg, "::") - var v interface{} - err := jsoniter.Unmarshal([]byte(arg), &v) - if err != nil { - color.Red(L("Arguments: %s\n"), err.Error()) - return - } - pargs = append(pargs, v) - - if !runSilent { - color.White("args[%d]: %s\n", i-1, arg) - } - - } else if strings.HasPrefix(arg, "\\::") { - arg := "::" + strings.TrimPrefix(arg, "\\::") - pargs = append(pargs, arg) - if !runSilent { - color.White("args[%d]: %s\n", i-1, arg) - } - - } else { - pargs = append(pargs, arg) - if !runSilent { - color.White("args[%d]: %s\n", i-1, arg) - } - } - - } - - // Start Tasks - itask.Start() - defer itask.Stop() - - // Start Schedules - ischedule.Start() - defer ischedule.Stop() - - process := process.NewWithContext(context.Background(), name, pargs...) - res, err := process.Exec() - if err != nil { - if !runSilent { - color.Red(L("Process: %s\n"), fmt.Sprintf("%s", strings.TrimPrefix(err.Error(), "Exception|404:"))) - return - } - fmt.Printf("%s\n", err.Error()) - return - } - - if !runSilent { - - if len(loadWarnings) > 0 { - fmt.Println(color.YellowString("---------------------------------")) - fmt.Println(color.YellowString(L("Warnings"))) - fmt.Println(color.YellowString("---------------------------------")) - for _, warning := range loadWarnings { - fmt.Println(color.YellowString("[%s] %s", warning.Widget, warning.Error)) - } - fmt.Printf("\n") - } - - color.White("--------------------------------------\n") - color.White(L("%s Response\n"), name) - color.White("--------------------------------------\n") - helper.Dump(res) - color.White("--------------------------------------\n") - color.Green(L("✨DONE✨\n")) - return - } - - // Silent mode output - switch res.(type) { - - case int, int8, int16, int32, int64, uint, uint8, uint16, uint32, uint64, float32, float64, bool: - fmt.Printf("%v\n", res) - return - - case string, []byte: - fmt.Printf("%s\n", res) - return - - default: - txt, err := jsoniter.Marshal(res) - if err != nil { - fmt.Printf("%s\n", err.Error()) - } - fmt.Printf("%s\n", txt) - } + runLocal(args) }, } func init() { runCmd.PersistentFlags().BoolVarP(&runSilent, "silent", "s", false, L("Silent mode")) + runCmd.PersistentFlags().StringVar(&runAuthPath, "auth", "", L("Path to credentials file")) +} + +// resolveCredential loads credential from --auth flag or default path. +func resolveCredential() *Credential { + if runAuthPath != "" { + cred, err := LoadCredentialFrom(runAuthPath) + if err != nil { + color.Red(" %s %s\n", L("Failed to load credentials:"), err) + os.Exit(1) + } + return cred + } + + cred, _ := LoadCredential() + return cred +} + +// runGRPC executes a process via the remote gRPC server. +func runGRPC(cred *Credential, args []string) { + if len(args) < 1 { + if !runSilent { + color.Red(L("Not enough arguments\n")) + color.White(share.BUILDNAME + " help\n") + } else { + fmt.Print(L("Not enough arguments\n")) + } + os.Exit(1) + } + + if cred.GRPCAddr == "" { + color.Red(" %s\n", L("No gRPC address in credentials. Please re-login.")) + os.Exit(1) + } + + name := args[0] + if !runSilent { + color.Green(L("Run: %s gRPC: %s\n"), name, cred.GRPCAddr) + } + + pargs := parseRunArgs(args[1:]) + + argsJSON, err := jsoniter.Marshal(pargs) + if err != nil { + color.Red(" %s %s\n", L("Arguments:"), err.Error()) + os.Exit(1) + } + + tm := taigrpc.NewTokenManager(cred.AccessToken, cred.RefreshToken, "", "") + client, err := taigrpc.Dial(cred.GRPCAddr, tm) + if err != nil { + color.Red(" %s %s\n", L("gRPC connect failed:"), err.Error()) + os.Exit(1) + } + defer client.Close() + + data, err := client.Run(context.Background(), name, argsJSON, 0) + if err != nil { + if !runSilent { + color.Red(" %s %s\n", L("Process:"), err.Error()) + } else { + fmt.Printf("%s\n", err.Error()) + } + os.Exit(1) + } + + if !runSilent { + color.White("--------------------------------------\n") + color.White(L("%s Response\n"), name) + color.White("--------------------------------------\n") + var res interface{} + if jsoniter.Unmarshal(data, &res) == nil { + helper.Dump(res) + } else { + fmt.Printf("%s\n", data) + } + color.White("--------------------------------------\n") + fmt.Printf("\033[32m✨DONE✨\033[0m \033[90mgRPC: %s\033[0m\n", cred.GRPCAddr) + } else { + fmt.Printf("%s\n", data) + } +} + +// runLocal executes a process locally (existing behavior). +func runLocal(args []string) { + defer share.SessionStop() + defer plugin.KillAll() + + defer func() { + err := exception.Catch(recover()) + if err != nil { + if !runSilent { + color.Red(L("Fatal: %s\n"), err.Error()) + return + } + fmt.Printf("%s\n", err.Error()) + } + }() + + // Auto-detect app root if not specified + if appPath == "" { + cwd, err := os.Getwd() + if err == nil { + if root, err := findAppRootFromPath(cwd); err == nil { + appPath = root + } + } + } + + Boot() + + // Set Runtime Mode + config.Conf.Runtime.Mode = "standard" + + cfg := config.Conf + cfg.Session.IsCLI = true + if len(args) < 1 { + if !runSilent { + color.Red(L("Not enough arguments\n")) + color.White(share.BUILDNAME + " help\n") + return + } + fmt.Print(L("Not enough arguments\n")) + return + } + + loadWarnings, err := engine.Load(cfg, engine.LoadOption{Action: "run"}) + if err != nil { + if !runSilent { + color.Red(L("Engine: %s\n"), err.Error()) + return + } + + fmt.Printf("%s\n", err.Error()) + return + } + + name := args[0] + if !runSilent { + color.Green(L("Run: %s\n"), name) + } + + pargs := parseRunArgs(args) + + // Start Tasks + itask.Start() + defer itask.Stop() + + // Start Schedules + ischedule.Start() + defer ischedule.Stop() + + p := process.NewWithContext(context.Background(), name, pargs...) + res, err := p.Exec() + if err != nil { + if !runSilent { + color.Red(L("Process: %s\n"), fmt.Sprintf("%s", strings.TrimPrefix(err.Error(), "Exception|404:"))) + return + } + fmt.Printf("%s\n", err.Error()) + return + } + + if !runSilent { + + if len(loadWarnings) > 0 { + fmt.Println(color.YellowString("---------------------------------")) + fmt.Println(color.YellowString(L("Warnings"))) + fmt.Println(color.YellowString("---------------------------------")) + for _, warning := range loadWarnings { + fmt.Println(color.YellowString("[%s] %s", warning.Widget, warning.Error)) + } + fmt.Printf("\n") + } + + color.White("--------------------------------------\n") + color.White(L("%s Response\n"), name) + color.White("--------------------------------------\n") + helper.Dump(res) + color.White("--------------------------------------\n") + color.Green(L("✨DONE✨\n")) + return + } + + // Silent mode output + switch res.(type) { + + case int, int8, int16, int32, int64, uint, uint8, uint16, uint32, uint64, float32, float64, bool: + fmt.Printf("%v\n", res) + return + + case string, []byte: + fmt.Printf("%s\n", res) + return + + default: + txt, err := jsoniter.Marshal(res) + if err != nil { + fmt.Printf("%s\n", err.Error()) + } + fmt.Printf("%s\n", txt) + } +} + +// parseRunArgs parses the CLI arguments into process arguments, handling :: prefixed JSON. +func parseRunArgs(args []string) []interface{} { + pargs := []interface{}{} + for i, arg := range args { + if i == 0 { + continue + } + + if strings.HasPrefix(arg, "::") { + raw := strings.TrimPrefix(arg, "::") + var v interface{} + err := jsoniter.Unmarshal([]byte(raw), &v) + if err != nil { + color.Red(L("Arguments: %s\n"), err.Error()) + return pargs + } + pargs = append(pargs, v) + if !runSilent { + color.White("args[%d]: %s\n", i-1, raw) + } + } else if strings.HasPrefix(arg, "\\::") { + cleaned := "::" + strings.TrimPrefix(arg, "\\::") + pargs = append(pargs, cleaned) + if !runSilent { + color.White("args[%d]: %s\n", i-1, cleaned) + } + } else { + pargs = append(pargs, arg) + if !runSilent { + color.White("args[%d]: %s\n", i-1, arg) + } + } + } + return pargs } // findAppRootFromPath finds the Yao application root directory by looking for app.yao diff --git a/grpc/IMPL.md b/grpc/IMPL.md index 578d2553..b3f0869b 100644 --- a/grpc/IMPL.md +++ b/grpc/IMPL.md @@ -193,7 +193,7 @@ Container token issuance uses existing `oauth.MakeAccessToken` / `oauth.MakeRefr Deliverable: `go build -o yao-grpc ./tai/grpc/cmd`. -### Phase 6: Device Flow + CLI auth ⏳ +### Phase 6: Device Flow + CLI auth ✅ Depends on: Phase 1. Three sub-phases with sequential dependency: 6.1 → 6.2 → 6.3. @@ -214,7 +214,7 @@ Backend endpoints for RFC 8628 Device Authorization Grant. Scaffolding already i Deliverable: Device flow endpoints functional — `POST /oauth/device_authorization` issues codes, `POST /oauth/token` with `grant_type=device_code` polls status. `POST /oauth/device/authorize` allows authenticated user to authorize device. -#### Phase 6.2: CUI auth/device page (frontend) ⏳ +#### Phase 6.2: CUI auth/device page (frontend) ✅ Depends on: Phase 6.1 (backend endpoints). Frontend-only task in **CUI repo**. @@ -222,8 +222,10 @@ Route: `/auth/device` (Umi convention-based routing → `pages/auth/device/index | Task | Detail | Status | |------|--------|--------| -| `pages/auth/device/index.tsx` | Device authorization page. User enters `user_code`, clicks Authorize. Uses `AuthLayout` + `AuthInput` + `AuthButton` from existing `pages/auth/components/`. | ⏳ Pending | -| `pages/auth/device/index.less` | Styles, follow `pages/auth/entry/index.less` pattern | ⏳ Pending | +| `pages/auth/device/index.tsx` | Device authorization page. User enters `user_code`, clicks Authorize. Uses `AuthLayout` + `AuthInput` + `AuthButton` from existing `pages/auth/components/`. Three states: input, success, error. i18n (zh/en), light/dark, system CSS variables only. | ✅ Done | +| `pages/auth/device/index.less` | Styles, follow `pages/auth/entry/mfa/index.less` pattern. Full responsive + dark theme. | ✅ Done | +| `openapi/user/auth.ts` | `AuthorizeDevice(userCode)` method — `POST /oauth/device/authorize` | ✅ Done | +| `layouts/index.tsx` | Register `['auth_device', '/auth/device']` in `STANDALONE_PAGES` | ✅ Done | Implementation: @@ -237,7 +239,7 @@ Implementation: Deliverable: `/auth/device` page. User authorizes CLI device login from browser. -#### Phase 6.3: CLI commands + TUI status bar ⏳ +#### Phase 6.3: CLI commands + TUI status bar ✅ Depends on: Phase 6.1 (backend) + Phase 6.2 (CUI page for end-to-end `yao login`). @@ -258,10 +260,13 @@ Stored as: `base64(json) → ~/.yao/credentials`. Prevents casual `cat` exposure | Task | Detail | Status | |------|--------|--------| -| `cmd/login.go` | `yao login --server ` — call device authorization endpoint, color-print device code + verification URL (no TUI), poll token endpoint with interval, on success base64-encode and save to `~/.yao/credentials` | ⏳ Pending | -| `cmd/logout.go` | `yao logout` — read credentials, revoke token via server, delete `~/.yao/credentials` | ⏳ Pending | -| `cmd/run.go` | Detect credentials → gRPC mode vs local mode. `--auth ` flag loads alternate credentials file (for bash scripting). `-s` (silent) mode: no TUI, pure output. gRPC mode with terminal: bubbletea TUI status bar. | ⏳ Pending | -| `cmd/tui_status.go` | bubbletea `StatusBarModel` — top-line persistent bar showing `user@host (gRPC)` + scope summary. Does not interfere with process output below. Uses existing bubbletea + lipgloss deps. | ⏳ Pending | +| `cmd/credential.go` | `Credential` struct, `LoadCredential`, `LoadCredentialFrom`, `SaveCredential`, `RemoveCredential` — base64-encoded JSON read/write to `~/.yao/credentials` | ✅ Done | +| `cmd/login.go` | `yao login --server ` — compute machine ID → `POST /oauth/register` (dynamic client) → `POST /oauth/device_authorization` → color-print device code + verification URL → poll `POST /oauth/token` with interval + slow_down handling → save to `~/.yao/credentials` | ✅ Done | +| `cmd/logout.go` | `yao logout` — read credentials, best-effort `POST /oauth/revoke`, delete `~/.yao/credentials` | ✅ Done | +| `cmd/run.go` | Detect credentials → gRPC mode vs local mode. `--auth ` flag loads alternate credentials file. `-s` (silent) mode: no TUI. gRPC mode renders TUI status bar then calls remote (gRPC call wiring pending Phase 4/5 integration). Local mode unchanged. | ✅ Done | +| `cmd/tui_status.go` | lipgloss `RenderStatusBar(cred)` — one-line persistent bar: `user (gRPC) │ scope: run,stream,...`. Rounded border, colored connection info. Hidden in silent mode. | ✅ Done | +| `cmd/root.go` | Register `loginCmd`, `logoutCmd` in root command | ✅ Done | +| i18n | All new strings have zh-CN translations via `langs` map | ✅ Done | **`yao run` behavior matrix:** @@ -314,17 +319,17 @@ Phase 1 (auth + server) ✅ │ ├───────────┬───────────┬──────────────────────┐ ▼ ▼ ▼ ▼ -Phase 2 ✅ Phase 3 ✅ Phase 4 ✅ Phase 6 (device flow + CLI) +Phase 2 ✅ Phase 3 ✅ Phase 4 ✅ Phase 6 ✅ (device flow + CLI) (handlers) (LLM/Agent) (Tai gateway) │ │ ┌───────┴───────┐ ▼ ▼ ▼ - Phase 5 ✅ 6.1 OAuth 6.2 CUI page - (yao-grpc) (backend) (frontend) + Phase 5 ✅ 6.1 ✅ 6.2 ✅ + (yao-grpc) (OAuth backend) (CUI page) │ │ └───────┬───────┘ ▼ - 6.3 CMD + TUI - (login/logout/run) + 6.3 ✅ + (CMD + TUI) --- V2 --- diff --git a/openapi/config.go b/openapi/config.go index b0dff37a..8e982a85 100644 --- a/openapi/config.go +++ b/openapi/config.go @@ -388,6 +388,7 @@ func (config *Config) OAuthConfig(appConfig config.Config) (*oauth.Config, error Cache: cacheStore, Store: dataStore, IssuerURL: config.OAuth.IssuerURL, + BaseURL: config.BaseURL, Signing: signingConfig, // Use the converted signing config Token: config.OAuth.Token, Security: config.OAuth.Security, diff --git a/openapi/oauth.go b/openapi/oauth.go index f306f6c9..52f19b1e 100644 --- a/openapi/oauth.go +++ b/openapi/oauth.go @@ -547,45 +547,15 @@ func (openapi *OpenAPI) oauthDeviceAuthorization(c *gin.Context) { // oauthDeviceAuthorize allows an authenticated user to authorize a pending device code. func (openapi *OpenAPI) oauthDeviceAuthorize(c *gin.Context) { - authHeader := c.GetHeader("Authorization") - if authHeader == "" || !strings.HasPrefix(authHeader, "Bearer ") { + tokenStr := extractBearerToken(c) + if tokenStr == "" { response.RespondWithSecureError(c, response.StatusUnauthorized, &response.ErrorResponse{ Code: types.ErrorInvalidGrant, ErrorDescription: "Bearer token required", }) return } - - tokenStr := strings.TrimPrefix(authHeader, "Bearer ") - oauthService := openapi.OAuth - introspection, err := oauthService.Introspect(c, tokenStr) - if err != nil || introspection == nil || !introspection.Active { - response.RespondWithSecureError(c, response.StatusUnauthorized, &response.ErrorResponse{ - Code: types.ErrorInvalidGrant, - ErrorDescription: "Invalid or expired token", - }) - return - } - - subject := introspection.Subject - if subject == "" { - response.RespondWithSecureError(c, response.StatusUnauthorized, &response.ErrorResponse{ - Code: types.ErrorInvalidGrant, - ErrorDescription: "Token has no subject", - }) - return - } - - userCode := c.PostForm("user_code") - if userCode == "" { - userCode = c.Query("user_code") - } - if userCode == "" { - response.RespondWithSecureError(c, response.StatusBadRequest, response.ErrInvalidRequest) - return - } - - svc, ok := oauthService.(*oauth.Service) + svc, ok := openapi.OAuth.(*oauth.Service) if !ok { response.RespondWithSecureError(c, response.StatusInternalServerError, &response.ErrorResponse{ Code: types.ErrorServerError, @@ -594,7 +564,52 @@ func (openapi *OpenAPI) oauthDeviceAuthorize(c *gin.Context) { return } - if err := svc.AuthorizeDevice(c, userCode, subject); err != nil { + tokenClaims, err := svc.VerifyToken(tokenStr) + if err != nil || tokenClaims == nil { + response.RespondWithSecureError(c, response.StatusUnauthorized, &response.ErrorResponse{ + Code: types.ErrorInvalidGrant, + ErrorDescription: "Invalid or expired token", + }) + return + } + + if tokenClaims.Subject == "" { + response.RespondWithSecureError(c, response.StatusUnauthorized, &response.ErrorResponse{ + Code: types.ErrorInvalidGrant, + ErrorDescription: "Token has no subject", + }) + return + } + + extraClaims := tokenClaims.Extra + if extraClaims == nil { + extraClaims = make(map[string]interface{}) + } + if tokenClaims.TeamID != "" { + extraClaims["team_id"] = tokenClaims.TeamID + } + if tokenClaims.TenantID != "" { + extraClaims["tenant_id"] = tokenClaims.TenantID + } + + userCode := c.PostForm("user_code") + if userCode == "" { + userCode = c.Query("user_code") + } + if userCode == "" { + var body struct { + UserCode string `json:"user_code"` + } + if c.ShouldBindJSON(&body) == nil { + userCode = body.UserCode + } + } + if userCode == "" { + response.RespondWithSecureError(c, response.StatusBadRequest, response.ErrInvalidRequest) + return + } + + if err := svc.AuthorizeDevice(c, userCode, tokenClaims.Subject, extraClaims); err != nil { if oauthErr, ok := err.(*response.ErrorResponse); ok { response.RespondWithSecureError(c, response.StatusBadRequest, oauthErr) } else { @@ -705,3 +720,16 @@ func (openapi *OpenAPI) getParam(c *gin.Context, key string) string { // Then try to get from POST form data (POST request) return c.PostForm(key) } + +// extractBearerToken reads the access token from Authorization header or cookie, +// matching the same logic as guard.getAccessToken. +func extractBearerToken(c *gin.Context) string { + if auth := c.GetHeader("Authorization"); strings.HasPrefix(auth, "Bearer ") { + return strings.TrimPrefix(auth, "Bearer ") + } + cookieName := response.GetCookieName("access_token") + if cookie, err := c.Cookie(cookieName); err == nil && cookie != "" { + return strings.TrimPrefix(cookie, "Bearer ") + } + return "" +} diff --git a/openapi/oauth/core.go b/openapi/oauth/core.go index 3cbb8411..9862a5ee 100644 --- a/openapi/oauth/core.go +++ b/openapi/oauth/core.go @@ -6,6 +6,7 @@ import ( "time" "github.com/yaoapp/yao/openapi/oauth/types" + "go.mongodb.org/mongo-driver/bson/primitive" ) // AuthorizationServer returns the authorization server endpoint URL @@ -667,8 +668,18 @@ func (s *Service) handleDeviceCodeGrant(ctx context.Context, client *types.Clien subject, _ := codeData["subject"].(string) s.consumeDeviceCode(deviceCode) + var extraClaims map[string]interface{} + if ec, ok := codeData["extra_claims"]; ok { + switch v := ec.(type) { + case map[string]interface{}: + extraClaims = v + case primitive.M: + extraClaims = map[string]interface{}(v) + } + } + expiresIn := int(s.config.Token.AccessTokenLifetime.Seconds()) - accessToken, err := s.generateAccessTokenWithScope(client.ClientID, scope, subject, expiresIn, nil) + accessToken, err := s.generateAccessTokenWithScope(client.ClientID, scope, subject, expiresIn, extraClaims) if err != nil { return nil, &types.ErrorResponse{ Code: types.ErrorServerError, @@ -683,7 +694,7 @@ func (s *Service) handleDeviceCodeGrant(ctx context.Context, client *types.Clien } if types.Contains(client.GrantTypes, types.GrantTypeRefreshToken) { - refreshToken, err := s.generateRefreshToken(client.ClientID, scope, subject, 0, nil) + refreshToken, err := s.generateRefreshToken(client.ClientID, scope, subject, 0, extraClaims) if err != nil { return nil, &types.ErrorResponse{ Code: types.ErrorServerError, diff --git a/openapi/oauth/device.go b/openapi/oauth/device.go index ed567513..7c5471c3 100644 --- a/openapi/oauth/device.go +++ b/openapi/oauth/device.go @@ -72,7 +72,7 @@ func (s *Service) DeviceAuthorization(ctx context.Context, clientID string, scop } // AuthorizeDevice allows an authenticated user to authorize a device code via user_code. -func (s *Service) AuthorizeDevice(ctx context.Context, userCode string, subject string) error { +func (s *Service) AuthorizeDevice(ctx context.Context, userCode string, subject string, extraClaims ...map[string]interface{}) error { if !s.config.Features.DeviceFlowEnabled { return &types.ErrorResponse{ Code: types.ErrorUnsupportedGrantType, @@ -86,7 +86,11 @@ func (s *Service) AuthorizeDevice(ctx context.Context, userCode string, subject formatted = normalized[:4] + "-" + normalized[4:] } - return s.authorizeDeviceCode(formatted, subject) + var claims map[string]interface{} + if len(extraClaims) > 0 { + claims = extraClaims[0] + } + return s.authorizeDeviceCode(formatted, subject, claims) } // generateUserCode generates a user-friendly code formatted as XXXX-XXXX. diff --git a/openapi/oauth/discovery.go b/openapi/oauth/discovery.go index b7f44dd7..b577c4e8 100644 --- a/openapi/oauth/discovery.go +++ b/openapi/oauth/discovery.go @@ -6,6 +6,7 @@ import ( "encoding/base64" "fmt" "math/big" + "strings" "github.com/yaoapp/yao/openapi/oauth/types" ) @@ -53,7 +54,7 @@ func (s *Service) JWKS(ctx context.Context) (*types.JWKSResponse, error) { // Endpoints returns a map of all available OAuth endpoints // This provides endpoint discovery for clients func (s *Service) Endpoints(ctx context.Context) (map[string]string, error) { - baseURL := s.config.IssuerURL + baseURL := strings.TrimRight(s.config.IssuerURL, "/") + s.config.BaseURL endpoints := map[string]string{ "authorization_endpoint": fmt.Sprintf("%s/oauth/authorize", baseURL), diff --git a/openapi/oauth/oauth.go b/openapi/oauth/oauth.go index b7012502..f313482e 100644 --- a/openapi/oauth/oauth.go +++ b/openapi/oauth/oauth.go @@ -57,6 +57,7 @@ type Config struct { // OAuth server metadata IssuerURL string `json:"issuer_url"` // JWT token issuer URL + BaseURL string `json:"base_url"` // API route prefix (e.g. "/v1") } // FeatureFlags represents feature toggle configuration diff --git a/openapi/oauth/token.go b/openapi/oauth/token.go index 586764bb..5d8b52d3 100644 --- a/openapi/oauth/token.go +++ b/openapi/oauth/token.go @@ -596,7 +596,7 @@ func (s *Service) getDeviceCodeData(deviceCode string) (map[string]interface{}, } // authorizeDeviceCode marks a device code as authorized via user_code lookup -func (s *Service) authorizeDeviceCode(userCode, subject string) error { +func (s *Service) authorizeDeviceCode(userCode, subject string, extraClaims map[string]interface{}) error { reverseData, exists := s.store.Get(s.userCodeKey(userCode)) if !exists { return &types.ErrorResponse{ @@ -626,6 +626,9 @@ func (s *Service) authorizeDeviceCode(userCode, subject string) error { codeData["status"] = "authorized" codeData["subject"] = subject + if extraClaims != nil { + codeData["extra_claims"] = extraClaims + } // Re-store with remaining TTL expiresAt, _ := codeData["expires_at"].(int64) diff --git a/openapi/well-known.go b/openapi/well-known.go index dd38fe82..85e42377 100644 --- a/openapi/well-known.go +++ b/openapi/well-known.go @@ -1,10 +1,14 @@ package openapi import ( + "fmt" + "net" "os" + "strconv" "strings" "github.com/gin-gonic/gin" + "github.com/yaoapp/yao/config" "github.com/yaoapp/yao/share" ) @@ -45,6 +49,7 @@ type YaoMetadata struct { // Dashboard configuration Dashboard string `json:"dashboard,omitempty"` // Admin dashboard root path + GRPC string `json:"grpc,omitempty"` // gRPC server address (e.g., "127.0.0.1:9099") Optional map[string]interface{} `json:"optional,omitempty"` // Optional settings // Developer information @@ -67,6 +72,7 @@ func (openapi *OpenAPI) yaoMetadata(c *gin.Context) { IssuerURL: openapi.Config.OAuth.IssuerURL, ServerURL: resolveServerURL(openapi.Config.OAuth.IssuerURL), Dashboard: "/" + dashboard, + GRPC: resolveGRPCAddr(c), Optional: share.App.Optional, } @@ -97,8 +103,46 @@ func resolveServerURL(issuerURL string) string { return "" } +// resolveGRPCAddr returns the gRPC server address for client discovery. +// Uses the request Host's IP with the configured gRPC port. +func resolveGRPCAddr(c *gin.Context) string { + cfg := config.Conf.GRPC + if strings.ToLower(cfg.Enabled) == "off" { + return "" + } + port := cfg.Port + if port == 0 { + port = 9099 + } + + host := cfg.Host + if host == "" || host == "0.0.0.0" { + reqHost := c.Request.Host + h, _, err := net.SplitHostPort(reqHost) + if err != nil { + h = reqHost + } + host = h + } else if strings.Contains(host, ",") { + host = strings.TrimSpace(strings.Split(host, ",")[0]) + } + + return fmt.Sprintf("%s:%s", host, strconv.Itoa(port)) +} + // oauthServerMetadata returns authorization server metadata - RFC 8414 -func (openapi *OpenAPI) oauthServerMetadata(c *gin.Context) {} +func (openapi *OpenAPI) oauthServerMetadata(c *gin.Context) { + if openapi.OAuth == nil { + c.JSON(503, gin.H{"error": "OAuth service not available"}) + return + } + metadata, err := openapi.OAuth.GetServerMetadata(c.Request.Context()) + if err != nil { + c.JSON(500, gin.H{"error": err.Error()}) + return + } + c.JSON(200, metadata) +} // oauthOpenIDConfiguration returns OpenID Connect configuration func (openapi *OpenAPI) oauthOpenIDConfiguration(c *gin.Context) {}