package credhelper import ( "bytes" "encoding/json" "fmt" "io" "net/http" "os" "time" ) // Device authorization API types type DeviceCodeRequest struct { DeviceName string `json:"device_name"` } type DeviceCodeResponse struct { DeviceCode string `json:"device_code"` UserCode string `json:"user_code"` VerificationURI string `json:"verification_uri"` ExpiresIn int `json:"expires_in"` Interval int `json:"interval"` } type DeviceTokenRequest struct { DeviceCode string `json:"device_code"` } type DeviceTokenResponse struct { DeviceSecret string `json:"device_secret,omitempty"` Handle string `json:"handle,omitempty"` DID string `json:"did,omitempty"` Error string `json:"error,omitempty"` } // AuthErrorResponse is the JSON error response from /auth/token type AuthErrorResponse struct { Error string `json:"error"` Message string `json:"message"` LoginURL string `json:"login_url,omitempty"` } // ValidationResult represents the result of credential validation type ValidationResult struct { Valid bool OAuthSessionExpired bool LoginURL string } // requestDeviceCode requests a device code from the AppView. // Returns the code response and resolved AppView URL. // Does not print anything — the caller controls UX. func requestDeviceCode(serverURL string) (*DeviceCodeResponse, string, error) { appViewURL := buildAppViewURL(serverURL) deviceName := hostname() reqBody, _ := json.Marshal(DeviceCodeRequest{DeviceName: deviceName}) resp, err := httpClient().Post(appViewURL+"/auth/device/code", "application/json", bytes.NewReader(reqBody)) if err != nil { return nil, appViewURL, fmt.Errorf("failed to request device code: %w", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { body, _ := io.ReadAll(resp.Body) return nil, appViewURL, fmt.Errorf("device code request failed: %s", string(body)) } var codeResp DeviceCodeResponse if err := json.NewDecoder(resp.Body).Decode(&codeResp); err != nil { return nil, appViewURL, fmt.Errorf("failed to decode device code response: %w", err) } return &codeResp, appViewURL, nil } // pollDeviceToken polls the token endpoint until authorization completes. // Does not print anything — the caller controls UX. // Returns the account on success, or an error on timeout/failure. func pollDeviceToken(appViewURL string, codeResp *DeviceCodeResponse) (*Account, error) { pollInterval := time.Duration(codeResp.Interval) * time.Second timeout := time.Duration(codeResp.ExpiresIn) * time.Second deadline := time.Now().Add(timeout) for time.Now().Before(deadline) { time.Sleep(pollInterval) tokenReqBody, _ := json.Marshal(DeviceTokenRequest{DeviceCode: codeResp.DeviceCode}) tokenResp, err := httpClient().Post(appViewURL+"/auth/device/token", "application/json", bytes.NewReader(tokenReqBody)) if err != nil { continue } var tokenResult DeviceTokenResponse if err := json.NewDecoder(tokenResp.Body).Decode(&tokenResult); err != nil { tokenResp.Body.Close() continue } tokenResp.Body.Close() if tokenResult.Error == "authorization_pending" { continue } if tokenResult.Error != "" { return nil, fmt.Errorf("authorization failed: %s", tokenResult.Error) } return &Account{ Handle: tokenResult.Handle, DID: tokenResult.DID, DeviceSecret: tokenResult.DeviceSecret, }, nil } return nil, fmt.Errorf("authorization timed out") } // validateCredentials checks if the credentials are still valid by making a test request func validateCredentials(appViewURL, handle, deviceSecret string) ValidationResult { client := httpClientWithTimeout(5*time.Second, nil) tokenURL := appViewURL + "/auth/token?service=" + appViewURL req, err := http.NewRequest("GET", tokenURL, nil) if err != nil { return ValidationResult{Valid: false} } req.SetBasicAuth(handle, deviceSecret) resp, err := client.Do(req) if err != nil { // Network error — assume credentials are valid but server unreachable return ValidationResult{Valid: true} } defer resp.Body.Close() if resp.StatusCode == http.StatusOK { return ValidationResult{Valid: true} } if resp.StatusCode == http.StatusUnauthorized { body, err := io.ReadAll(resp.Body) if err == nil { var authErr AuthErrorResponse if json.Unmarshal(body, &authErr) == nil && authErr.Error == "oauth_session_expired" { return ValidationResult{ Valid: false, OAuthSessionExpired: true, LoginURL: authErr.LoginURL, } } } return ValidationResult{Valid: false} } // Any other error = assume valid (don't re-auth on server issues) return ValidationResult{Valid: true} } // hostname returns the machine hostname, or a fallback. func hostname() string { name, err := os.Hostname() if err != nil { return "Unknown Device" } return name }