- 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.
342 lines
9.5 KiB
Go
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
|
|
}
|