yao/cmd/login.go
Max c6e1c449e1 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.
2026-03-04 16:55:29 +08:00

342 lines
9.5 KiB
Go

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
}