From c6e1c449e1c6f24e946e4957eb66a24b6147d0e6 Mon Sep 17 00:00:00 2001 From: Max Date: Wed, 4 Mar 2026 16:55:29 +0800 Subject: [PATCH] Enhance Tai service readiness checks and OAuth device flow - Improve health check logic in CI workflows for both HTTP and gRPC readiness of the Tai service, ensuring clearer error reporting if the service fails to start. - Update the OAuth Device Flow implementation to support additional claims during device authorization, enhancing the flexibility of the authorization process. - Refactor the `AuthorizeDevice` method to accept extra claims, allowing for more detailed user context during authorization. - Introduce a new utility function to extract bearer tokens from requests, streamlining token handling across the OpenAPI service. These changes enhance the robustness of service readiness checks and improve the OAuth device authorization flow, contributing to a more reliable and flexible authentication mechanism. --- .github/workflows/pr-test.yml | 20 ++ .github/workflows/unit-test.yml | 20 ++ cmd/credential.go | 118 ++++++++++ cmd/login.go | 342 ++++++++++++++++++++++++++++ cmd/logout.go | 80 +++++++ cmd/root.go | 2 + cmd/run.go | 392 ++++++++++++++++++++------------ grpc/IMPL.md | 33 +-- openapi/config.go | 1 + openapi/oauth.go | 96 +++++--- openapi/oauth/core.go | 15 +- openapi/oauth/device.go | 8 +- openapi/oauth/discovery.go | 3 +- openapi/oauth/oauth.go | 1 + openapi/oauth/token.go | 5 +- openapi/well-known.go | 46 +++- 16 files changed, 980 insertions(+), 202 deletions(-) create mode 100644 cmd/credential.go create mode 100644 cmd/login.go create mode 100644 cmd/logout.go 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) {}