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.
This commit is contained in:
Max 2026-03-04 16:55:29 +08:00
parent 9dc99d8b8e
commit c6e1c449e1
16 changed files with 980 additions and 202 deletions

View file

@ -1704,22 +1704,42 @@ jobs:
-p 8080:8080 -p 9100:9100 -p 2375:2375 -p 6080:6080 -p 6443:6443 \ -p 8080:8080 -p 9100:9100 -p 2375:2375 -p 6080:6080 -p 6443:6443 \
-e TAI_K8S_UPSTREAM="tcp://${K3D_IP}:6443" \ -e TAI_K8S_UPSTREAM="tcp://${K3D_IP}:6443" \
yaoapp/tai:latest yaoapp/tai:latest
TAI_HTTP_READY=false
for i in $(seq 1 30); do for i in $(seq 1 30); do
if curl -sf http://127.0.0.1:8080/healthz > /dev/null 2>&1; then if curl -sf http://127.0.0.1:8080/healthz > /dev/null 2>&1; then
echo "Tai HTTP is ready" echo "Tai HTTP is ready"
TAI_HTTP_READY=true
break break
fi fi
echo "Waiting for Tai HTTP... ($i)" echo "Waiting for Tai HTTP... ($i)"
sleep 1 sleep 1
done 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 for i in $(seq 1 15); do
if nc -z 127.0.0.1 9100 2>/dev/null; then if nc -z 127.0.0.1 9100 2>/dev/null; then
echo "Tai gRPC is ready" echo "Tai gRPC is ready"
TAI_GRPC_READY=true
break break
fi fi
echo "Waiting for Tai gRPC... ($i)" echo "Waiting for Tai gRPC... ($i)"
sleep 1 sleep 1
done 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 - name: Generate kubeconfig for Tai K8s proxy
run: | run: |

View file

@ -1258,22 +1258,42 @@ jobs:
-p 8080:8080 -p 9100:9100 -p 2375:2375 -p 6080:6080 -p 6443:6443 \ -p 8080:8080 -p 9100:9100 -p 2375:2375 -p 6080:6080 -p 6443:6443 \
-e TAI_K8S_UPSTREAM="tcp://${K3D_IP}:6443" \ -e TAI_K8S_UPSTREAM="tcp://${K3D_IP}:6443" \
yaoapp/tai:latest yaoapp/tai:latest
TAI_HTTP_READY=false
for i in $(seq 1 30); do for i in $(seq 1 30); do
if curl -sf http://127.0.0.1:8080/healthz > /dev/null 2>&1; then if curl -sf http://127.0.0.1:8080/healthz > /dev/null 2>&1; then
echo "Tai HTTP is ready" echo "Tai HTTP is ready"
TAI_HTTP_READY=true
break break
fi fi
echo "Waiting for Tai HTTP... ($i)" echo "Waiting for Tai HTTP... ($i)"
sleep 1 sleep 1
done 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 for i in $(seq 1 15); do
if nc -z 127.0.0.1 9100 2>/dev/null; then if nc -z 127.0.0.1 9100 2>/dev/null; then
echo "Tai gRPC is ready" echo "Tai gRPC is ready"
TAI_GRPC_READY=true
break break
fi fi
echo "Waiting for Tai gRPC... ($i)" echo "Waiting for Tai gRPC... ($i)"
sleep 1 sleep 1
done 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 - name: Generate kubeconfig for Tai K8s proxy
run: | run: |

118
cmd/credential.go Normal file
View file

@ -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
}

342
cmd/login.go Normal file
View file

@ -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
}

80
cmd/logout.go Normal file
View file

@ -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:"] = "服务发现失败:"
}

View file

@ -189,6 +189,8 @@ func init() {
inspectCmd, inspectCmd,
startCmd, startCmd,
runCmd, runCmd,
loginCmd,
logoutCmd,
// getCmd, // getCmd,
// dumpCmd, // dumpCmd,
// restoreCmd, // restoreCmd,

View file

@ -18,173 +18,271 @@ import (
"github.com/yaoapp/yao/engine" "github.com/yaoapp/yao/engine"
ischedule "github.com/yaoapp/yao/schedule" ischedule "github.com/yaoapp/yao/schedule"
"github.com/yaoapp/yao/share" "github.com/yaoapp/yao/share"
taigrpc "github.com/yaoapp/yao/tai/grpc"
itask "github.com/yaoapp/yao/task" itask "github.com/yaoapp/yao/task"
) )
var runSilent = false var runSilent = false
var runAuthPath string
var runCmd = &cobra.Command{ var runCmd = &cobra.Command{
Use: "run", Use: "run",
Short: L("Execute process"), Short: L("Execute process"),
Long: L("Execute process"), Long: L("Execute process"),
Run: func(cmd *cobra.Command, args []string) { Run: func(cmd *cobra.Command, args []string) {
defer share.SessionStop()
defer plugin.KillAll()
defer func() { // Resolve credential: --auth flag > ~/.yao/credentials > nil (local mode)
err := exception.Catch(recover()) cred := resolveCredential()
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 cred != nil {
if appPath == "" { runGRPC(cred, args)
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 return
} }
loadWarnings, err := engine.Load(cfg, engine.LoadOption{Action: "run"}) runLocal(args)
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)
}
}, },
} }
func init() { func init() {
runCmd.PersistentFlags().BoolVarP(&runSilent, "silent", "s", false, L("Silent mode")) 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 // findAppRootFromPath finds the Yao application root directory by looking for app.yao

View file

@ -193,7 +193,7 @@ Container token issuance uses existing `oauth.MakeAccessToken` / `oauth.MakeRefr
Deliverable: `go build -o yao-grpc ./tai/grpc/cmd`. 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. 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. 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**. 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 | | 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.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/index.less` pattern | ⏳ Pending | | `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: Implementation:
@ -237,7 +239,7 @@ Implementation:
Deliverable: `/auth/device` page. User authorizes CLI device login from browser. 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`). 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 | | Task | Detail | Status |
|------|--------|--------| |------|--------|--------|
| `cmd/login.go` | `yao login --server <url>` — 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/credential.go` | `Credential` struct, `LoadCredential`, `LoadCredentialFrom`, `SaveCredential`, `RemoveCredential` — base64-encoded JSON read/write to `~/.yao/credentials` | ✅ Done |
| `cmd/logout.go` | `yao logout` — read credentials, revoke token via server, delete `~/.yao/credentials` | ⏳ Pending | | `cmd/login.go` | `yao login --server <url>` — 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/run.go` | Detect credentials → gRPC mode vs local mode. `--auth <path>` 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/logout.go` | `yao logout` — read credentials, best-effort `POST /oauth/revoke`, delete `~/.yao/credentials` | ✅ Done |
| `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/run.go` | Detect credentials → gRPC mode vs local mode. `--auth <path>` 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:** **`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) │ (handlers) (LLM/Agent) (Tai gateway) │
│ ┌───────┴───────┐ │ ┌───────┴───────┐
▼ ▼ ▼ ▼ ▼ ▼
Phase 5 ✅ 6.1 OAuth 6.2 CUI page Phase 5 ✅ 6.1 ✅ 6.2 ✅
(yao-grpc) (backend) (frontend) (yao-grpc) (OAuth backend) (CUI page)
│ │ │ │
└───────┬───────┘ └───────┬───────┘
6.3 CMD + TUI 6.3
(login/logout/run) (CMD + TUI)
--- V2 --- --- V2 ---

View file

@ -388,6 +388,7 @@ func (config *Config) OAuthConfig(appConfig config.Config) (*oauth.Config, error
Cache: cacheStore, Cache: cacheStore,
Store: dataStore, Store: dataStore,
IssuerURL: config.OAuth.IssuerURL, IssuerURL: config.OAuth.IssuerURL,
BaseURL: config.BaseURL,
Signing: signingConfig, // Use the converted signing config Signing: signingConfig, // Use the converted signing config
Token: config.OAuth.Token, Token: config.OAuth.Token,
Security: config.OAuth.Security, Security: config.OAuth.Security,

View file

@ -547,45 +547,15 @@ func (openapi *OpenAPI) oauthDeviceAuthorization(c *gin.Context) {
// oauthDeviceAuthorize allows an authenticated user to authorize a pending device code. // oauthDeviceAuthorize allows an authenticated user to authorize a pending device code.
func (openapi *OpenAPI) oauthDeviceAuthorize(c *gin.Context) { func (openapi *OpenAPI) oauthDeviceAuthorize(c *gin.Context) {
authHeader := c.GetHeader("Authorization") tokenStr := extractBearerToken(c)
if authHeader == "" || !strings.HasPrefix(authHeader, "Bearer ") { if tokenStr == "" {
response.RespondWithSecureError(c, response.StatusUnauthorized, &response.ErrorResponse{ response.RespondWithSecureError(c, response.StatusUnauthorized, &response.ErrorResponse{
Code: types.ErrorInvalidGrant, Code: types.ErrorInvalidGrant,
ErrorDescription: "Bearer token required", ErrorDescription: "Bearer token required",
}) })
return return
} }
svc, ok := openapi.OAuth.(*oauth.Service)
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)
if !ok { if !ok {
response.RespondWithSecureError(c, response.StatusInternalServerError, &response.ErrorResponse{ response.RespondWithSecureError(c, response.StatusInternalServerError, &response.ErrorResponse{
Code: types.ErrorServerError, Code: types.ErrorServerError,
@ -594,7 +564,52 @@ func (openapi *OpenAPI) oauthDeviceAuthorize(c *gin.Context) {
return 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 { if oauthErr, ok := err.(*response.ErrorResponse); ok {
response.RespondWithSecureError(c, response.StatusBadRequest, oauthErr) response.RespondWithSecureError(c, response.StatusBadRequest, oauthErr)
} else { } 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) // Then try to get from POST form data (POST request)
return c.PostForm(key) 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 ""
}

View file

@ -6,6 +6,7 @@ import (
"time" "time"
"github.com/yaoapp/yao/openapi/oauth/types" "github.com/yaoapp/yao/openapi/oauth/types"
"go.mongodb.org/mongo-driver/bson/primitive"
) )
// AuthorizationServer returns the authorization server endpoint URL // 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) subject, _ := codeData["subject"].(string)
s.consumeDeviceCode(deviceCode) 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()) 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 { if err != nil {
return nil, &types.ErrorResponse{ return nil, &types.ErrorResponse{
Code: types.ErrorServerError, Code: types.ErrorServerError,
@ -683,7 +694,7 @@ func (s *Service) handleDeviceCodeGrant(ctx context.Context, client *types.Clien
} }
if types.Contains(client.GrantTypes, types.GrantTypeRefreshToken) { 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 { if err != nil {
return nil, &types.ErrorResponse{ return nil, &types.ErrorResponse{
Code: types.ErrorServerError, Code: types.ErrorServerError,

View file

@ -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. // 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 { if !s.config.Features.DeviceFlowEnabled {
return &types.ErrorResponse{ return &types.ErrorResponse{
Code: types.ErrorUnsupportedGrantType, Code: types.ErrorUnsupportedGrantType,
@ -86,7 +86,11 @@ func (s *Service) AuthorizeDevice(ctx context.Context, userCode string, subject
formatted = normalized[:4] + "-" + normalized[4:] 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. // generateUserCode generates a user-friendly code formatted as XXXX-XXXX.

View file

@ -6,6 +6,7 @@ import (
"encoding/base64" "encoding/base64"
"fmt" "fmt"
"math/big" "math/big"
"strings"
"github.com/yaoapp/yao/openapi/oauth/types" "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 // Endpoints returns a map of all available OAuth endpoints
// This provides endpoint discovery for clients // This provides endpoint discovery for clients
func (s *Service) Endpoints(ctx context.Context) (map[string]string, error) { 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{ endpoints := map[string]string{
"authorization_endpoint": fmt.Sprintf("%s/oauth/authorize", baseURL), "authorization_endpoint": fmt.Sprintf("%s/oauth/authorize", baseURL),

View file

@ -57,6 +57,7 @@ type Config struct {
// OAuth server metadata // OAuth server metadata
IssuerURL string `json:"issuer_url"` // JWT token issuer URL 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 // FeatureFlags represents feature toggle configuration

View file

@ -596,7 +596,7 @@ func (s *Service) getDeviceCodeData(deviceCode string) (map[string]interface{},
} }
// authorizeDeviceCode marks a device code as authorized via user_code lookup // 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)) reverseData, exists := s.store.Get(s.userCodeKey(userCode))
if !exists { if !exists {
return &types.ErrorResponse{ return &types.ErrorResponse{
@ -626,6 +626,9 @@ func (s *Service) authorizeDeviceCode(userCode, subject string) error {
codeData["status"] = "authorized" codeData["status"] = "authorized"
codeData["subject"] = subject codeData["subject"] = subject
if extraClaims != nil {
codeData["extra_claims"] = extraClaims
}
// Re-store with remaining TTL // Re-store with remaining TTL
expiresAt, _ := codeData["expires_at"].(int64) expiresAt, _ := codeData["expires_at"].(int64)

View file

@ -1,10 +1,14 @@
package openapi package openapi
import ( import (
"fmt"
"net"
"os" "os"
"strconv"
"strings" "strings"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/yaoapp/yao/config"
"github.com/yaoapp/yao/share" "github.com/yaoapp/yao/share"
) )
@ -45,6 +49,7 @@ type YaoMetadata struct {
// Dashboard configuration // Dashboard configuration
Dashboard string `json:"dashboard,omitempty"` // Admin dashboard root path 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 Optional map[string]interface{} `json:"optional,omitempty"` // Optional settings
// Developer information // Developer information
@ -67,6 +72,7 @@ func (openapi *OpenAPI) yaoMetadata(c *gin.Context) {
IssuerURL: openapi.Config.OAuth.IssuerURL, IssuerURL: openapi.Config.OAuth.IssuerURL,
ServerURL: resolveServerURL(openapi.Config.OAuth.IssuerURL), ServerURL: resolveServerURL(openapi.Config.OAuth.IssuerURL),
Dashboard: "/" + dashboard, Dashboard: "/" + dashboard,
GRPC: resolveGRPCAddr(c),
Optional: share.App.Optional, Optional: share.App.Optional,
} }
@ -97,8 +103,46 @@ func resolveServerURL(issuerURL string) string {
return "" 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 // 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 // oauthOpenIDConfiguration returns OpenID Connect configuration
func (openapi *OpenAPI) oauthOpenIDConfiguration(c *gin.Context) {} func (openapi *OpenAPI) oauthOpenIDConfiguration(c *gin.Context) {}