Support OAuth 2.0 login flow for Monzo API
After some toil and lots of learning, monzo_ynab is receiving access and refresh tokens from Monzo. I can now use these tokens to fetch my transactions from the past 24 hours and then forward them along to YNAB. If YNAB's API requires OAuth 2.0 login flow for authorization, I should be able to set that up in about an hour, which would be much faster than it took me to setup the login flow for Monzo. Learning can be a powerful thing. See the TODOs scattered around for a general idea of some (but not all) of the work that remains. TL;DR - Package monzo_ynab with buildGo - Move some utility functions to sibling packages - Add a README with a project overview, installation instructions, and a brief note about my ideas for deployment Note: I have some outstanding questions about how to manage state in Go. Should I use channels? Should I use a library? Are top-level variables enough? Answers to some or all of these questions and more coming soon...
This commit is contained in:
parent
138070f3f6
commit
fafabc6e4a
6 changed files with 203 additions and 81 deletions
|
@ -1,81 +0,0 @@
|
||||||
// Creating a job to import Monzo transactions into YNAB.
|
|
||||||
//
|
|
||||||
// This is going to run N times per 24 hours.
|
|
||||||
|
|
||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"log"
|
|
||||||
"net/http"
|
|
||||||
"os"
|
|
||||||
"os/exec"
|
|
||||||
)
|
|
||||||
|
|
||||||
var (
|
|
||||||
clientId = os.Getenv("client_id")
|
|
||||||
clientSecret = os.Getenv("client_secret")
|
|
||||||
accessToken = nil
|
|
||||||
refreshToken = nil
|
|
||||||
)
|
|
||||||
|
|
||||||
const (
|
|
||||||
state = "xyz123"
|
|
||||||
redirectUri = "http://localhost:8080/authorize"
|
|
||||||
)
|
|
||||||
|
|
||||||
func getAccessCode(string authCode) {
|
|
||||||
form := map[string]string{
|
|
||||||
"grant_type": "authorization_code",
|
|
||||||
"client_id": client_id,
|
|
||||||
"client_secret": client_secret,
|
|
||||||
"redirect_uri": redirectUri,
|
|
||||||
"code": authCode,
|
|
||||||
}
|
|
||||||
json := map[string]string{
|
|
||||||
"access_token": "access_token",
|
|
||||||
"client_id": "client_id",
|
|
||||||
"expires_in": 21600,
|
|
||||||
"refresh_token": "refresh_token",
|
|
||||||
"token_type": "Bearer",
|
|
||||||
"user_id": "user_id",
|
|
||||||
}
|
|
||||||
|
|
||||||
// TODO: Handle retry with backoff logic here.
|
|
||||||
resp, error := http.Post("https://api.monzo.com/oauth2/token", form.Form(), json.Json())
|
|
||||||
if err != nil {
|
|
||||||
log.Fatal("Could not exchange authorization code for an access token.")
|
|
||||||
}
|
|
||||||
|
|
||||||
resp.Body()
|
|
||||||
}
|
|
||||||
|
|
||||||
func handleRedirect(w http.ResponseWriter, r *http.Request) {
|
|
||||||
// assert that `r.state` is the same as `state`.
|
|
||||||
params := r.URL.Query()
|
|
||||||
|
|
||||||
reqState := params["state"][0]
|
|
||||||
reqCode := params["code"][0]
|
|
||||||
|
|
||||||
if reqState != state {
|
|
||||||
log.Fatal(fmt.Sprintf("Value for state returned by Monzo does not equal our state. %s != %s", reqState, state))
|
|
||||||
}
|
|
||||||
|
|
||||||
go getAccessCode(reqCode)
|
|
||||||
|
|
||||||
fmt.Printf("Received the authorization code from Monzo: %s", reqCode)
|
|
||||||
fmt.Fprintf(w, fmt.Sprintf("Authorization code: %s", reqCode))
|
|
||||||
}
|
|
||||||
|
|
||||||
func authorizeClient() {
|
|
||||||
url :=
|
|
||||||
fmt.Sprintf("https://auth.monzo.com/?client_id=%s&redirect_uri=%s&response_type=code&state=%s",
|
|
||||||
clientId, redirectUri, state)
|
|
||||||
exec.Command("google-chrome", url).Start()
|
|
||||||
}
|
|
||||||
|
|
||||||
func main() {
|
|
||||||
authorizeClient()
|
|
||||||
http.HandleFunc("/authorize", handleRedirect)
|
|
||||||
go log.Fatal(http.ListenAndServe(":8080", nil))
|
|
||||||
}
|
|
41
monzo_ynab/README.md
Normal file
41
monzo_ynab/README.md
Normal file
|
@ -0,0 +1,41 @@
|
||||||
|
# monzo_ynab
|
||||||
|
|
||||||
|
Exporting Monzo transactions to my YouNeedABudget.com (i.e. YNAB) account. YNAB
|
||||||
|
unfortunately doesn't currently offer an Monzo integration. As a workaround and
|
||||||
|
a practical excuse to learn Go, I decided to write one myself.
|
||||||
|
|
||||||
|
This job is going to run N times per 24 hours. Monzo offers webhooks for
|
||||||
|
reacting to certain types of events. I don't expect I'll need realtime data for
|
||||||
|
my YNAB integration. That may change, however, so it's worth noting.
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
Like many other packages in this repository, `monzo_ynab` is packaged using
|
||||||
|
Nix. To install and use, you have two options:
|
||||||
|
|
||||||
|
You can install using `nix-build` and then run the resulting
|
||||||
|
`./result/bin/monzo_ynab`.
|
||||||
|
|
||||||
|
```shell
|
||||||
|
> nix-build . && ./result/bin/monzo_ynab
|
||||||
|
```
|
||||||
|
|
||||||
|
Or you can install using `nix-env` if you'd like to create the `monzo_ynab`
|
||||||
|
symlink.
|
||||||
|
|
||||||
|
```shell
|
||||||
|
> nix-env -f ~/briefcase/monzo_ynab -i
|
||||||
|
```
|
||||||
|
|
||||||
|
## Deployment
|
||||||
|
|
||||||
|
While this project is currently not deployed, my plan is to host it on Google
|
||||||
|
Cloud and run it as a Cloud Run application. What I don't yet know is whether or
|
||||||
|
not this is feasible or a good idea. One complication that I foresee is that the
|
||||||
|
OAuth 2.0 login flow requires a web browser until the access token and refresh
|
||||||
|
tokens are acquired. I'm unsure how to workaround this at the moment.
|
||||||
|
|
||||||
|
For more information about the general packaging and deployment strategies I'm
|
||||||
|
currently using, refer to the [deployments][deploy] writeup.
|
||||||
|
|
||||||
|
[deploy]: ../deploy/README.md
|
10
monzo_ynab/default.nix
Normal file
10
monzo_ynab/default.nix
Normal file
|
@ -0,0 +1,10 @@
|
||||||
|
{ depot ? import <depot> {}, ... }:
|
||||||
|
|
||||||
|
depot.buildGo.program {
|
||||||
|
name = "monzo_ynab";
|
||||||
|
srcs = [
|
||||||
|
./utils.go
|
||||||
|
./main.go
|
||||||
|
./monzo.go
|
||||||
|
];
|
||||||
|
}
|
125
monzo_ynab/main.go
Normal file
125
monzo_ynab/main.go
Normal file
|
@ -0,0 +1,125 @@
|
||||||
|
// Exporting Monzo transactions to my YouNeedABudget.com (i.e. YNAB)
|
||||||
|
// account. YNAB unfortunately doesn't currently offer an Monzo integration. As
|
||||||
|
// a workaround and a practical excuse to learn Go, I decided to write one
|
||||||
|
// myself.
|
||||||
|
//
|
||||||
|
// This job is going to run N times per 24 hours. Monzo offers webhooks for
|
||||||
|
// reacting to certain types of events. I don't expect I'll need realtime data
|
||||||
|
// for my YNAB integration. That may change, however, so it's worth noting.
|
||||||
|
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
)
|
||||||
|
|
||||||
|
////////////////////////////////////////////////////////////////////////////////
|
||||||
|
// Constants
|
||||||
|
////////////////////////////////////////////////////////////////////////////////
|
||||||
|
|
||||||
|
var (
|
||||||
|
clientId = os.Getenv("client_id")
|
||||||
|
clientSecret = os.Getenv("client_secret")
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
redirectURI = "http://localhost:8080/authorization-code"
|
||||||
|
// TODO(wpcarro): Consider generating a random string for the state when the
|
||||||
|
// application starts instead of hardcoding it here.
|
||||||
|
state = "xyz123"
|
||||||
|
)
|
||||||
|
|
||||||
|
////////////////////////////////////////////////////////////////////////////////
|
||||||
|
// Business Logic
|
||||||
|
////////////////////////////////////////////////////////////////////////////////
|
||||||
|
|
||||||
|
// This is the response returned from Monzo when we exchange our authorization
|
||||||
|
// code for an access token. While Monzo returns additional fields, I'm only
|
||||||
|
// interested in AccessToken and RefreshToken.
|
||||||
|
type accessTokenResponse struct {
|
||||||
|
AccessToken string `json:"access_token"`
|
||||||
|
RefreshToken string `json:"refresh_token"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO(wpcarro): Replace http.PostForm and other similar calls with
|
||||||
|
// client.postForm. The default http.Get and other methods doesn't timeout, so
|
||||||
|
// it's better to create a configured client with a value for the timeout.
|
||||||
|
|
||||||
|
func getAccessToken(code string) {
|
||||||
|
res, err := http.PostForm("https://api.monzo.com/oauth2/token", url.Values{
|
||||||
|
"grant_type": {"authorization_code"},
|
||||||
|
"client_id": {clientId},
|
||||||
|
"client_secret": {clientSecret},
|
||||||
|
"redirect_uri": {redirectURI},
|
||||||
|
"code": {code},
|
||||||
|
})
|
||||||
|
failOn(err)
|
||||||
|
defer res.Body.Close()
|
||||||
|
|
||||||
|
payload := accessTokenResponse{}
|
||||||
|
json.NewDecoder(res.Body).Decode(&payload)
|
||||||
|
|
||||||
|
log.Printf("Access token: %s\n", payload.AccessToken)
|
||||||
|
log.Printf("Refresh token: %s\n", payload.AccessToken)
|
||||||
|
}
|
||||||
|
|
||||||
|
func listenHttp(sigint chan os.Signal) {
|
||||||
|
// Use a go-routine to listen for interrupt signals to shutdown our HTTP
|
||||||
|
// server.
|
||||||
|
go func() {
|
||||||
|
<-sigint
|
||||||
|
// TODO(wpcarro): Do we need context here? I took this example from the
|
||||||
|
// example on golang.org.
|
||||||
|
log.Println("Warning: I should be shutting down and closing the connection here, but I'm not.")
|
||||||
|
close(sigint)
|
||||||
|
}()
|
||||||
|
|
||||||
|
log.Fatal(http.ListenAndServe(":8080", http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
|
||||||
|
// 1. Get authorization code from Monzo.
|
||||||
|
if req.URL.Path == "/authorization-code" {
|
||||||
|
params := req.URL.Query()
|
||||||
|
reqState := params["state"][0]
|
||||||
|
code := params["code"][0]
|
||||||
|
|
||||||
|
if reqState != state {
|
||||||
|
log.Fatalf("Value for state returned by Monzo does not equal our state. %s != %s", reqState, state)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO(wpcarro): Add a more interesting authorization confirmation
|
||||||
|
// screen -- or even nothing at all.
|
||||||
|
fmt.Fprintf(w, "Authorized!")
|
||||||
|
|
||||||
|
// Exchange the authorization code for an access token.
|
||||||
|
getAccessToken(code)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("Unhandled request: %v\n", *req)
|
||||||
|
})))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Open a web browser to allow the user to authorize this application.
|
||||||
|
// TODO(wpcarro): Prefer using an environment variable for the web browser
|
||||||
|
// instead of assuming it will be google-chrome.
|
||||||
|
func authorizeClient() {
|
||||||
|
url := fmt.Sprintf("https://auth.monzo.com/?client_id=%s&redirect_uri=%s&response_type=code&state=%s", clientId, redirectURI, state)
|
||||||
|
exec.Command("google-chrome", url).Start()
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
sigint := make(chan os.Signal, 1)
|
||||||
|
// TODO(wpcarro): Remove state here. I'm using as a hack to prevent my
|
||||||
|
// program from halting before I'd like it to. Once I'm more comfortable
|
||||||
|
// using channels, this should be a trivial change.
|
||||||
|
state := make(chan bool)
|
||||||
|
|
||||||
|
authorizeClient()
|
||||||
|
listenHttp(sigint)
|
||||||
|
<-state
|
||||||
|
}
|
18
monzo_ynab/monzo.go
Normal file
18
monzo_ynab/monzo.go
Normal file
|
@ -0,0 +1,18 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TODO: Support a version of this function that doesn't need the token
|
||||||
|
// parameter.
|
||||||
|
func monzoGet(token, string, endpoint string) {
|
||||||
|
client := &http.Client{}
|
||||||
|
req, err := http.NewRequest("GET", fmt.Sprintf("https://api.monzo.com/%s", endpoint), nil)
|
||||||
|
failOn(err)
|
||||||
|
req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", token))
|
||||||
|
res, err := client.Do(req)
|
||||||
|
failOn(err)
|
||||||
|
fmt.Println(res)
|
||||||
|
}
|
9
monzo_ynab/utils.go
Normal file
9
monzo_ynab/utils.go
Normal file
|
@ -0,0 +1,9 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import "log"
|
||||||
|
|
||||||
|
func failOn(err error) {
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in a new issue