Xero OAuth 1.0 VBA Connector
Contents
Let’s program like it’s 1999 … I have the unfortunate memory of actually programming around that time and having a little exposure to Visual Basic 6.0 which is what VBA in Excel is all about. One of my first projects in 1995 at Credit Suisse I was building a COM adapter in C++ to feed an excel spread sheet from a Tibco feed. Way before webservices, JSON etc.got popular. A sense of deja vu.
I did this Oauth implementation with with Excel 2016 (that is what Office 365 says…) on Windows. This will not work on any other platform. I haven’t gotten around to putting a JSON parser in there yet to actually do something useful with the data which comes back.
Note: This code is here for historical interest only. If you are not an iNTERFACEWARE customer please understand that this VBA code is not supported. For your information as this project progressed we found in practice it was much easier to leverage Iguana to extract the information from Xero and map into a set of SQLite tables to mirror the data from Xero.
The reason for this is that some APIs like those that get invoices, only give you the detail information if you loop around and get pages of data. Other things like credit notes have to be retrieved one at time which is really slow. Repeating invoice templates have another pattern to get those. None of these things are that consistent. So the architecture we ended up going for was getting Iguana to do all this back end extraction and then serving the data up via Excel using the Excel adapter.
That has been working really effectively for us in terms of being able to cross reference and correlate data with our CRM system – we tried out salesforce.com for a while and found it cumbersome so we switched to using pipedrive.com together with Harvest and Basecamp to handle the professional services function. It’s been very effective to do things like looking at revenue per territory, make sure data is consistent between the two systems and so on.
We’ve also been able to give feeds of data to our account managers to help give the visibility into accounts receivable, orders etc. within their territories which has had a really positive impact.
Excel is a nice spreadsheet. Shame about the VBA language. I thought this would be a trivial project to knock off but it turned out to a little non trivial due to the impoverished nature of Visual Basic environment.
This script is huge in comparison to the Python and Lua implementations.
The code here has two extra dependencies to worry about:
- It uses the openssl command line tool. This doesn’t seem like a big deal since you need to use that anyway to generate your private and public key pem files to register with Xero.
- Out of the box VBA doesn’t have a dictionary (aka hash table) so I needed to add the “Reference” through Tools->References for C:\Windows\System32\scrrun.dll which is the Microsoft Scripting Runtime. This gave me access to a Dictionary object.
To get this code working and understand it I would recommend reading through the rest of this article. I would suggest starting with my Lua example if you are an Iguana customer, or the python example if you are not. Starting with this example will be hard.
Working with VBA presented a lot of challenges that didn’t exist in Lua and Python.
No easy way to get GMT unix epoch time for the oauth_timestamp.
So I hacked it with this code DateDiff(“s”, #1/1/1970#, Now()) + TIMEZONE_OFFSET * 360. For my timezone in Toronto I picked the value of 5. It will probably break with daylight savings. More work could be done there.
No easy way to invoke a process and fetch its output.
I needed to do this for the openssl signing. Non trivial in VBA. I had to borrow some code I found that invokes the native WIN32 APIs CreateProcess to get that working. It makes for some fairly scary looking code but it works.
Now with more digging one could probably research a native COM object that supports RSA-SHA1 signing and that would enable one to get rid of much of this code.
No easy way to write a straight binary file with newlines being left alone
I finally found this code and used it:
Private Sub StringToFile(FileName As String, Content As String)
Const adTypeBinary = 1
Const adSaveCreateOverWrite = 2
Dim Data() As Byte Data = StrConv(Content, vbFromUnicode)
Dim BinaryStream Set BinaryStream = CreateObject("ADODB.Stream")
BinaryStream.Type = adTypeBinary
BinaryStream.Open
BinaryStream.Write Data
BinaryStream.SaveToFile FileName, adSaveCreateOverWrite
End Sub
VBA gives you a lot of grief the way that strings are UTF16 strings instead of going the single byte route with UTF8.
No easy sorting solutions
There isn’t any easy built in sorting functionality in VBA either. So to sort the parameters for OAuth signing I ended up putting the keys into an array and sorting with a bubble sort.
Not super efficient but fine for the purpose.
No built in URI or base64 Encoding
I scrounged some code from the net which did the trick. Sure does add to the volume of code for this thing though.
WinHttp.WinHttpRequest.5.1 for HTTP requests
This was one part of the whole code which was actually a little easier than using command line code. This object made it fairly easy to package up the HTTP request and send it off.
No JSON Parser
I have to go and do a bit more scrounging off the net to find one and try it out. I’ll leave that as an exercise to the reader. Anyway here is the code. If you are not familiar with Oauth 1.0 and Xero I would recommend reading my explanation about the Lua and Python implementation.
' These are constants required for base64 encoding
Private Const clOneMask = 16515072 '000000 111111 111111 111111
Private Const clTwoMask = 258048 '111111 000000 111111 111111
Private Const clThreeMask = 4032 '111111 111111 000000 111111
Private Const clFourMask = 63 '111111 111111 111111 000000
Private Const clHighMask = 16711680 '11111111 00000000 00000000
Private Const clMidMask = 65280 '00000000 11111111 00000000
Private Const clLowMask = 255 '00000000 00000000 11111111
Private Const cl2Exp18 = 262144 '2 to the 18th power
Private Const cl2Exp12 = 4096 '2 to the 12th
Private Const cl2Exp6 = 64 '2 to the 6th
Private Const cl2Exp8 = 256 '2 to the 8th
Private Const cl2Exp16 = 65536 '2 to the 16th
' This is a load of horrendous code just to be able to have the privillege of invoking a process and getting the standard output out
' I need this to invoke openssl for certificate signing
Private Type SECURITY_ATTRIBUTES
nLength As Long
lpSecurityDescriptor As Long
bInheritHandle As Long
End Type
Private Type PROCESS_INFORMATION
hProcess As Long
hThread As Long
dwProcessId As Long
dwThreadId As Long
End Type
Private Type STARTUPINFO
cb As Long
lpReserved As Long
lpDesktop As Long
lpTitle As Long
dwX As Long
dwY As Long
dwXSize As Long
dwYSize As Long
dwXCountChars As Long
dwYCountChars As Long
dwFillAttribute As Long
dwFlags As Long
wShowWindow As Integer
cbReserved2 As Integer
lpReserved2 As Byte
hStdInput As Long
hStdOutput As Long
hStdError As Long
End Type
Private Const WAIT_INFINITE As Long = (-1&)
Private Const STARTF_USESHOWWINDOW As Long = &H1
Private Const STARTF_USESTDHANDLES As Long = &H100
Private Const SW_HIDE As Long = 0&
Private Declare Function CreatePipe Lib "kernel32" (phReadPipe As Long, phWritePipe As Long, lpPipeAttributes As SECURITY_ATTRIBUTES, ByVal nSize As Long) As Long
Private Declare Function CreateProcess Lib "kernel32" Alias "CreateProcessA" (ByVal lpApplicationName As Long, ByVal lpCommandLine As String, lpProcessAttributes As Any, lpThreadAttributes As Any, ByVal bInheritHandles As Long, ByVal dwCreationFlags As Long, lpEnvironment As Any, ByVal lpCurrentDriectory As String, lpStartupInfo As STARTUPINFO, lpProcessInformation As PROCESS_INFORMATION) As Long
Private Declare Function ReadFile Lib "kernel32" (ByVal hFile As Long, lpBuffer As Any, ByVal nNumberOfBytesToRead As Long, lpNumberOfBytesRead As Long, lpOverlapped As Any) As Long
Private Declare Function CloseHandle Lib "kernel32" (ByVal hObject As Long) As Long
Private Declare Function WaitForSingleObject Lib "kernel32" (ByVal hHandle As Long, ByVal dwMilliseconds As Long) As Long
Private Declare Function GetExitCodeProcess Lib "kernel32" (ByVal hProcess As Long, lpExitCode As Long) As Long
Private Declare Sub GetStartupInfo Lib "kernel32" Alias "GetStartupInfoA" (lpStartupInfo As STARTUPINFO)
Private Declare Function GetFileSize Lib "kernel32" (ByVal hFile As Long, lpFileSizeHigh As Long) As Long
Public Function Redirect(szBinaryPath As String, szCommandLn As String) As String
Dim tSA_CreatePipe As SECURITY_ATTRIBUTES
Dim tSA_CreateProcessPrc As SECURITY_ATTRIBUTES
Dim tSA_CreateProcessThrd As SECURITY_ATTRIBUTES
Dim tSA_CreateProcessPrcInfo As PROCESS_INFORMATION
Dim tStartupInfo As STARTUPINFO
Dim hRead As Long
Dim hWrite As Long
Dim bRead As Long
Dim abytBuff() As Byte
Dim lngResult As Long
Dim szFullCommand As String
Dim lngExitCode As Long
Dim lngSizeOf As Long
tSA_CreatePipe.nLength = Len(tSA_CreatePipe)
tSA_CreatePipe.lpSecurityDescriptor = 0&
tSA_CreatePipe.bInheritHandle = True
tSA_CreateProcessPrc.nLength = Len(tSA_CreateProcessPrc)
tSA_CreateProcessThrd.nLength = Len(tSA_CreateProcessThrd)
If (CreatePipe(hRead, hWrite, tSA_CreatePipe, 0&) <> 0&) Then
tStartupInfo.cb = Len(tStartupInfo)
GetStartupInfo tStartupInfo
With tStartupInfo
.hStdOutput = hWrite
.hStdError = hWrite
.dwFlags = STARTF_USESHOWWINDOW Or STARTF_USESTDHANDLES
.wShowWindow = SW_HIDE
End With
szFullCommand = """" & szBinaryPath & """" & " " & szCommandLn
lngResult = CreateProcess(0&, szFullCommand, tSA_CreateProcessPrc, tSA_CreateProcessThrd, True, 0&, 0&, vbNullString, tStartupInfo, tSA_CreateProcessPrcInfo)
If (lngResult <> 0&) Then
lngResult = WaitForSingleObject(tSA_CreateProcessPrcInfo.hProcess, WAIT_INFINITE)
lngSizeOf = GetFileSize(hRead, 0&)
If (lngSizeOf > 0) Then
ReDim abytBuff(lngSizeOf - 1)
If ReadFile(hRead, abytBuff(0), UBound(abytBuff) + 1, bRead, ByVal 0&) Then
Redirect = StrConv(abytBuff, vbUnicode)
End If
End If
Call GetExitCodeProcess(tSA_CreateProcessPrcInfo.hProcess, lngExitCode)
CloseHandle tSA_CreateProcessPrcInfo.hThread
CloseHandle tSA_CreateProcessPrcInfo.hProcess
' Eliot - I commented this out
'If (lngExitCode <> 0&) Then Err.Raise vbObject + 1235&, "GetExitCodeProcess", "Non-zero Application exist code"
CloseHandle hWrite
CloseHandle hRead
Else
Err.Raise vbObject + 1236&, "CreateProcess", "CreateProcess Failed, Code: " & Err.LastDllError
End If
End If
End Function
' End of all the stuff needed for the "Redirect" function to invoke a process and get it's output
' Base 64 encoding function.
Public Function Encode64(sString As String) As String
Dim bTrans(63) As Byte, lPowers8(255) As Long, lPowers16(255) As Long, bOut() As Byte, bIn() As Byte
Dim lChar As Long, lTrip As Long, iPad As Integer, lLen As Long, lTemp As Long, lPos As Long, lOutSize As Long
For lTemp = 0 To 63 'Fill the translation table.
Select Case lTemp
Case 0 To 25
bTrans(lTemp) = 65 + lTemp 'A - Z
Case 26 To 51
bTrans(lTemp) = 71 + lTemp 'a - z
Case 52 To 61
bTrans(lTemp) = lTemp - 4 '1 - 0
Case 62
bTrans(lTemp) = 43 'Chr(43) = "+"
Case 63
bTrans(lTemp) = 47 'Chr(47) = "/"
End Select
Next lTemp
For lTemp = 0 To 255 'Fill the 2^8 and 2^16 lookup tables.
lPowers8(lTemp) = lTemp * cl2Exp8
lPowers16(lTemp) = lTemp * cl2Exp16
Next lTemp
iPad = Len(sString) Mod 3 'See if the length is divisible by 3
If iPad Then 'If not, figure out the end pad and resize the input.
iPad = 3 - iPad
sString = sString & String(iPad, Chr(0))
End If
bIn = StrConv(sString, vbFromUnicode) 'Load the input string.
lLen = ((UBound(bIn) + 1) \ 3) * 4 'Length of resulting string.
lTemp = lLen \ 72 'Added space for vbCrLfs.
lOutSize = ((lTemp * 2) + lLen) - 1 'Calculate the size of the output buffer.
ReDim bOut(lOutSize) 'Make the output buffer.
lLen = 0 'Reusing this one, so reset it.
For lChar = LBound(bIn) To UBound(bIn) Step 3
lTrip = lPowers16(bIn(lChar)) + lPowers8(bIn(lChar + 1)) + bIn(lChar + 2) 'Combine the 3 bytes
lTemp = lTrip And clOneMask 'Mask for the first 6 bits
bOut(lPos) = bTrans(lTemp \ cl2Exp18) 'Shift it down to the low 6 bits and get the value
lTemp = lTrip And clTwoMask 'Mask for the second set.
bOut(lPos + 1) = bTrans(lTemp \ cl2Exp12) 'Shift it down and translate.
lTemp = lTrip And clThreeMask 'Mask for the third set.
bOut(lPos + 2) = bTrans(lTemp \ cl2Exp6) 'Shift it down and translate.
bOut(lPos + 3) = bTrans(lTrip And clFourMask) 'Mask for the low set.
If lLen = 68 Then 'Ready for a newline
bOut(lPos + 4) = 13 'Chr(13) = vbCr
bOut(lPos + 5) = 10 'Chr(10) = vbLf
lLen = 0 'Reset the counter
lPos = lPos + 6
Else
lLen = lLen + 4
lPos = lPos + 4
End If
Next lChar
If bOut(lOutSize) = 10 Then lOutSize = lOutSize - 2 'Shift the padding chars down if it ends with CrLf.
If iPad = 1 Then 'Add the padding chars if any.
bOut(lOutSize) = 61 'Chr(61) = "="
ElseIf iPad = 2 Then
bOut(lOutSize) = 61
bOut(lOutSize - 1) = 61
End If
Encode64 = StrConv(bOut, vbUnicode) 'Convert back to a string and return it.
End Function
' This is used to build an array of keys to sort them along with the BubbleSortArray
Private Sub BuildArray(Dict As Dictionary, TempArray)
Dim Count, Key
Count = 0
ReDim TempArray(Dict.Count - 1)
For Each Key In Dict.Keys
TempArray(Count) = Key
Count = Count + 1
Next
End Sub
Sub BubbleSortArray(TempArray)
Dim i, j, Temp
For i = 0 To UBound(TempArray)
For j = 0 To i
If StrComp(TempArray(j), TempArray(i)) > 0 Then
'Swap the array positions
Temp = TempArray(j)
TempArray(j) = TempArray(i)
TempArray(i) = Temp
End If
Next
Next
End Sub
' URLEncoding because VBA ain't got anything built in...
Private Function URLEncode(StringVal As String, Optional SpaceAsPlus As Boolean = False) As String
Dim StringLen As Long: StringLen = Len(StringVal)
If StringLen > 0 Then
ReDim Result(StringLen) As String
Dim i As Long, CharCode As Integer
Dim Char As String, Space As String
If SpaceAsPlus Then Space = "+" Else Space = "%20"
For i = 1 To StringLen
Char = Mid$(StringVal, i, 1)
CharCode = Asc(Char)
Select Case CharCode
Case 97 To 122, 65 To 90, 48 To 57, 45, 46, 95, 126
Result(i) = Char
Case 32
Result(i) = Space
Case 0 To 15
Result(i) = "%0" & Hex(CharCode)
Case Else
Result(i) = "%" & Hex(CharCode)
End Select
Next i
URLEncode = Join(Result, "")
End If
End Function
Private Function ConcatenateSigParams(AllParams As Dictionary) As String
Dim Result As String
Result = ""
BuildArray AllParams, SortedKeys
BubbleSortArray SortedKeys
For i = 0 To UBound(SortedKeys)
Result = Result & SortedKeys(i) & "=" & URLEncode(AllParams.Item(SortedKeys(i))) & "&"
Next
ConcatenateSigParams = Left(Result, Len(Result) - 1)
End Function
Private Sub StringToFile(FileName As String, Content As String)
Const adTypeBinary = 1
Const adSaveCreateOverWrite = 2
Dim Data() As Byte
Data = StrConv(Content, vbFromUnicode)
Dim BinaryStream
Set BinaryStream = CreateObject("ADODB.Stream")
BinaryStream.Type = adTypeBinary
BinaryStream.Open
BinaryStream.Write Data
BinaryStream.SaveToFile FileName, adSaveCreateOverWrite
End Sub
Private Function SignText(Text As String, KeyPath As String, TempDir As String) As String
Dim SigFile As String
Dim Output As String
StringToFile TempDir & "request.txt", Text
SignText = Redirect("openssl", "dgst -sha1 -sign " & KeyPath & " -binary " & TempDir & "request.txt")
SignText = Encode64(SignText)
SignText = Replace(SignText, vbCrLf, "") ' get rid of newlines into the base64 output - ugh
End Function
Private Function AuthInfo(AuthDict As Dictionary) As String
AuthInfo = ""
For Each It In AuthDict
AuthInfo = AuthInfo & " " & It & "=""" + AuthDict.Item(It) & ""","
Next
End Function
Private Function MakeHttpRequest(Params As Dictionary, Headers As Dictionary, Url As String) As String
Dim Request As Object
Set Request = CreateObject("WinHttp.WinHttpRequest.5.1")
If (Params.Count > 0) Then
Url = Url & "?"
For Each It In Params
Url = Url & It & "=" & URLEncode(Params.Item(It), True) & "&"
Next
Url = Left(Url, Len(Url) - 1)
End If
Request.Open "GET", Url, False
For Each It In Headers
Request.SetRequestHeader It, Headers.Item(It)
Next
Request.Send
MakeHttpRequest = Request.responseText
End Function
Private Sub XeroFetch_Click()
Dim Params As Scripting.Dictionary
Dim Headers As Scripting.Dictionary
Dim Auth As Scripting.Dictionary
Dim AllParams As Scripting.Dictionary
Dim TempDir As String
Dim Url As String
Dim PrivateKey As String
Dim CONSUMER_KEY As String
Dim SortedParamAuthString As String
Dim SignatureText As String
Dim Signature As String
Set Params = New Dictionary
Set Auth = New Dictionary
Set Headers = New Dictionary
Set AllParams = New Dictionary
' TODO YOU MUST EDIT THESE PARAMETERS FOR YOUR SETUP
CONSUMER_KEY = "C00WGMXDTS5QSXWVN5WDOAJ1JHBRKA"
TempDir = "C:\temp\"
PrivateKey = "Y:\xero2\privatekey.pem"
TIMEZONE_OFFSET = 5 ' It's hard in VB to get the darn time zone offset
' END EDITING OF SITE SPECIFIC PARAMETERS
Url = "https://api.xero.com/api.xro/2.0/Contacts"
Params.Add "where", "Name == ""Expresso 31"""
Headers.Add "Accept", "application/json"
' Horrendous hack to get the unix epoch time and correct it for our time zone
Auth.Add "oauth_timestamp", CStr(DateDiff("s", #1/1/1970#, Now()) + TIMEZONE_OFFSET * 3600)
Auth.Add "oauth_nonce", CStr(Auth.Item("oauth_timestamp")) & CStr(Round(Rnd() * 100000000))
Auth.Add "oauth_version", "1.0"
Auth.Add "oauth_signature_method", "RSA-SHA1"
Auth.Add "oauth_consumer_key", CONSUMER_KEY
Auth.Add "oauth_token", CONSUMER_KEY
For Each It In Params
AllParams.Add It, Params.Item(It)
Next
For Each It In Auth
AllParams.Add It, Auth.Item(It)
Next
SortedParamAuthString = ConcatenateSigParams(AllParams)
SignatureText = "GET&" & URLEncode(Url, False) & "&" & URLEncode(SortedParamAuthString)
Signature = SignText(SignatureText, PrivateKey, TempDir)
Headers.Add "Authorization", "OAuth" & AuthInfo(Auth) & " oauth_signature=""" + URLEncode(Signature) & """"
Result = MakeHttpRequest(Params, Headers, Url)
MsgBox Result
End Sub