about summary refs log tree commit diff
path: root/monzo_ynab
diff options
context:
space:
mode:
authorWilliam Carroll <wpcarro@gmail.com>2020-02-09T01·07+0000
committerWilliam Carroll <wpcarro@gmail.com>2020-02-10T10·06+0000
commit7f8a5176ce4bebfb01fa5333791c8f14ad8cef45 (patch)
tree179d0d3e745dde23da32bb73d435478cee1dc145 /monzo_ynab
parente3ee0734e512b7e994fe38e39479b35dba8ee6b7 (diff)
Create server for managing Monzo credentials
I created a server to manage my access and refresh tokens. This server exposes a
larger API than it needs to at the moment, but that should change. The goal is
to expose a GET at /token to retrieve a valid access token. The server should
take care of refreshing tokens before they expire and getting entirely new
tokens, should they become so stale that I need to re-authorize my application.

A lot of my development of this project has been clumsy. I'm new to Go; I didn't
understand OAuth2.0; I'm learning concurrent programming (outside of the context
of comfortable Elixir/Erlang).

My habits for writing programs in compiled languages feels amateurish. I find
myself dropping log.Println's all over the source code when I should be using
proper debugging tools like Delve and properly logging with things like
httputil.Dump{Request,Response}.

The application right now is in a transitional state. There is still plenty of
code in main.go that belongs in tokens.go. For instance, the client
authorization code belongs in the tokens server.

Another question I haven't answered is where is the monzo client that I can use
to make function calls like `monzo.Transactions` or `monzo.Accounts`?

The benefit of having a tokens server is that it allows me to maintain state of
the tokens while I'm developing. This way, I can stop and start main.go without
disturbing the state of the access tokens. Of course this isn't the primary
benefit, which is to abstract over the OAuth details and expose an API
that gives me an access token whenever I request one.

The first benefit that I listed could and perhaps should be solved by
introducing some simple persistence. I'd like to write the access tokens to disk
when I shutdown the tokens server and read them from disk when I start the
tokens server. This will come. I could have done this before introducing the
tokens server, and it would have saved me a few hours I think.

Where has my time gone? Mostly I've been re-authorizing my client
unnecessarily. This process is expensive because it opens a web browser, asks me
to enter my email address, sends me an email, I then click the link in that
email. Overall this takes maybe 1-3 minutes in total. Before my tokens server
existed, however, I was doing this about 10-20 times per hour. It's a little
disappointing that I didn't rectify this earlier. I'd like to remain vigilant
and avoid making similar workflow mistakes as I move ahead.
Diffstat (limited to 'monzo_ynab')
-rw-r--r--monzo_ynab/main.go149
-rw-r--r--monzo_ynab/requests.txt22
-rw-r--r--monzo_ynab/tokens.go183
-rw-r--r--monzo_ynab/utils.go40
4 files changed, 342 insertions, 52 deletions
diff --git a/monzo_ynab/main.go b/monzo_ynab/main.go
index 07f3af9a1c..1709ffda78 100644
--- a/monzo_ynab/main.go
+++ b/monzo_ynab/main.go
@@ -10,11 +10,15 @@
 package main
 
 import (
+	"bytes"
 	"encoding/json"
 	"fmt"
+	"io/ioutil"
 	"log"
 	"net/http"
+	"net/http/httputil"
 	"net/url"
+	"strings"
 	"os"
 	"os/exec"
 )
@@ -24,6 +28,7 @@ import (
 ////////////////////////////////////////////////////////////////////////////////
 
 var (
+	accountId    = os.Getenv("monzo_account_id")
 	clientId     = os.Getenv("monzo_client_id")
 	clientSecret = os.Getenv("monzo_client_secret")
 )
@@ -45,13 +50,27 @@ const (
 type accessTokenResponse struct {
 	AccessToken  string `json:"access_token"`
 	RefreshToken string `json:"refresh_token"`
+	ExpiresIn    int    `json:"expires_in"`
+}
+
+type setTokensRequest struct {
+	AccessToken  string `json:"access_token"`
+	RefreshToken string `json:"refresh_token"`
+	ExpiresIn    int    `json:"expires_in"`
+}
+
+type Tokens struct {
+	AccessToken  string `json:"access_token"`
+	RefreshToken string `json:"refresh_token"`
+	ExpiresIn    int    `json:"expires_in"`
 }
 
 // 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) {
+// Returns the access token and refresh tokens for the Monzo API.
+func getTokens(code string) *Tokens {
 	res, err := http.PostForm("https://api.monzo.com/oauth2/token", url.Values{
 		"grant_type":    {"authorization_code"},
 		"client_id":     {clientId},
@@ -59,67 +78,93 @@ func getAccessToken(code string) {
 		"redirect_uri":  {redirectURI},
 		"code":          {code},
 	})
-	failOn(err)
+	if err != nil {
+		log.Fatal(err)
+	}
 	defer res.Body.Close()
+	payload := &accessTokenResponse{}
+	json.NewDecoder(res.Body).Decode(payload)
 
-	payload := accessTokenResponse{}
-	json.NewDecoder(res.Body).Decode(&payload)
-
-	log.Printf("Access token: %s\n", payload.AccessToken)
-	log.Printf("Refresh token: %s\n", payload.AccessToken)
+	return &Tokens{payload.AccessToken, payload.RefreshToken, payload.ExpiresIn}
 }
 
-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]
+// TODO(wpcarro): Prefer using an environment variable for the web browser
+// instead of assuming it will be google-chrome.
+// Open a web browser to allow the user to authorize this application. Return
+// the authorization code sent from Monzo.
+func getAuthCode() string {
+	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()
 
-			if reqState != state {
-				log.Fatalf("Value for state returned by Monzo does not equal our state. %s != %s", reqState, state)
+	authCode := make(chan string)
+	go func() {
+		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)
+				}
+				authCode <- code
+
+				fmt.Fprintf(w, "Authorized!")
+			} else {
+				log.Printf("Unhandled request: %v\n", *req)
 			}
+		})))
+	}()
+	result := <-authCode
+	return result
+}
 
-			// 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)
-	})))
+// TODO(wpcarro): Move this logic out of here and into the tokens server.
+func authorize() {
+	authCode := getAuthCode()
+	tokens := getTokens(authCode)
+	client := &http.Client{}
+
+	payload, _ := json.Marshal(setTokensRequest{
+		tokens.AccessToken,
+		tokens.RefreshToken,
+		tokens.ExpiresIn})
+	log.Printf("Access token: %s\n", tokens.AccessToken)
+	log.Printf("Refresh token: %s\n", tokens.RefreshToken)
+	log.Printf("Expires: %s\n", tokens.ExpiresIn)
+	req, _ := http.NewRequest("POST", "http://localhost:4242/set-tokens", bytes.NewBuffer(payload))
+	req.Header.Set("Content-Type", "application/json")
+	_, err := client.Do(req)
+	if err != nil {
+		log.Fatal(err)
+	}
 }
 
-// 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()
+// Retrieves the access token from the tokens server.
+func getAccessToken() string {
+	return simpleGet("http://localhost:4242/token")
 }
 
 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
+	accessToken := getAccessToken()
+	// authHeaders := map[string]string{
+	// 	"Authorization": fmt.Sprintf("Bearer %s", accessToken),
+	// }
+
+	client := &http.Client{}
+	form := url.Values{"account_id": {accountId}}
+	req, _ := http.NewRequest("GET", "https://api.monzo.com/transactions", strings.NewReader(form.Encode()))
+	req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", accessToken))
+	req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
+	bytes, _ := httputil.DumpRequest(req, true)
+	fmt.Println(string(bytes))
+	res, _ := client.Do(req)
+	bytes, _ = httputil.DumpResponse(res, true)
+	fmt.Println(string(bytes))
+
+	// res := simpleGet("https://api.monzo.com/accounts", authHeaders, true)
+	// fmt.Println(res)
+
+	os.Exit(0)
 }
diff --git a/monzo_ynab/requests.txt b/monzo_ynab/requests.txt
index 4e35e6b6c8..5698dfee37 100644
--- a/monzo_ynab/requests.txt
+++ b/monzo_ynab/requests.txt
@@ -34,3 +34,25 @@ Content-Type: application/x-www-form-urlencoded
 Authorization: Bearer :monzo-access-token
 grant_type=refresh_token&client_id=:monzo-client-id&client_secret=:monzo-client-secret&refresh_token=:monzo-refresh-token
 
+################################################################################
+# Tokens server
+################################################################################
+:tokens = http://localhost:4242
+
+# Get tokens
+GET :tokens/tokens
+
+# Get application state for debugging purposes
+GET :tokens/state
+
+# Force refresh tokens
+POST :tokens/refresh-tokens
+
+# Set tokens
+POST :tokens/set-tokens
+Content-Type: application/json
+{
+  "access_token": "access-token",
+  "refresh_token": "refresh-token",
+  "expires_in": 120
+}
diff --git a/monzo_ynab/tokens.go b/monzo_ynab/tokens.go
new file mode 100644
index 0000000000..47e991e561
--- /dev/null
+++ b/monzo_ynab/tokens.go
@@ -0,0 +1,183 @@
+// Creating a Tokens server to manage my access and refresh tokens. Keeping this
+// as a separate server allows me to develop and use the access tokens without
+// going through client authorization.
+package main
+
+////////////////////////////////////////////////////////////////////////////////
+// Dependencies
+////////////////////////////////////////////////////////////////////////////////
+
+import (
+	"bytes"
+	"encoding/json"
+	"fmt"
+	"log"
+	"net/http"
+	"net/url"
+	"os"
+	"time"
+)
+
+////////////////////////////////////////////////////////////////////////////////
+// Types
+////////////////////////////////////////////////////////////////////////////////
+
+// This is the response from Monzo's API after we request an access token
+// refresh.
+type refreshTokenResponse struct {
+	AccessToken  string `json:"access_token"`
+	RefreshToken string `json:"refresh_token"`
+	ClientId     string `json:"client_id"`
+	ExpiresIn    int    `json:"expires_in"`
+}
+
+// This is the shape of the request from clients wishing to set state of the
+// server.
+type setTokensRequest struct {
+	AccessToken  string `json:"access_token"`
+	RefreshToken string `json:"refresh_token"`
+	ExpiresIn    int    `json:"expires_in"`
+}
+
+// This is our application state.
+type state struct {
+	accessToken  string `json:"access_token"`
+	refreshToken string `json:"refresh_token"`
+}
+
+type readMsg struct {
+	sender chan state
+}
+
+type writeMsg struct {
+	state state
+}
+
+type channels struct {
+	reads  chan readMsg
+	writes chan writeMsg
+}
+
+////////////////////////////////////////////////////////////////////////////////
+// Top-level Definitions
+////////////////////////////////////////////////////////////////////////////////
+
+var chans = &channels{
+	reads:  make(chan readMsg),
+	writes: make(chan writeMsg),
+}
+
+var (
+	monzoClientId      = os.Getenv("monzo_client_id")
+	monzoClientSecret  = os.Getenv("monzo_client_secret")
+	cachedAccessToken  = os.Getenv("monzo_cached_access_token")
+	cachedRefreshToken = os.Getenv("monzo_cached_access_token")
+)
+
+////////////////////////////////////////////////////////////////////////////////
+// Utils
+////////////////////////////////////////////////////////////////////////////////
+
+// Schedule a token refresh for `expiresIn` seconds using the provided
+// `refreshToken`. This will update the application state with the access token
+// and schedule an additional token refresh for the newly acquired tokens.
+func scheduleTokenRefresh(expiresIn int, refreshToken string) {
+	duration := time.Second * time.Duration(expiresIn)
+	timestamp := time.Now().Local().Add(duration)
+	log.Printf("Scheduling token refresh for %v\n", timestamp)
+	time.Sleep(duration)
+	log.Println("Refreshing tokens now...")
+	access, refresh := refreshTokens(refreshToken)
+	log.Println("Successfully refreshed tokens.")
+	chans.writes <- writeMsg{state{access, refresh}}
+}
+
+// Exchange existing credentials for a new access token and `refreshToken`. Also
+// schedule the next refresh. This function returns the newly acquired access
+// token and refresh token.
+func refreshTokens(refreshToken string) (string, string) {
+	// TODO(wpcarro): Support retries with exponential backoff.
+	res, err := http.PostForm("https://api.monzo.com/oauth2/token", url.Values{
+		"grant_type":    {"refresh_token"},
+		"client_id":     {monzoClientId},
+		"client_secret": {monzoClientSecret},
+		"refresh_token": {refreshToken},
+	})
+	if err != nil {
+		log.Println(res)
+		log.Fatal("The request to Monzo to refresh our access token failed.", err)
+	}
+	defer res.Body.Close()
+	payload := &refreshTokenResponse{}
+	err = json.NewDecoder(res.Body).Decode(payload)
+	if err != nil {
+		log.Println(res)
+		log.Fatal("Could not decode the JSON response from Monzo.", err)
+	}
+	go scheduleTokenRefresh(payload.ExpiresIn, payload.RefreshToken)
+
+	return payload.AccessToken, payload.RefreshToken
+}
+
+////////////////////////////////////////////////////////////////////////////////
+// Main
+////////////////////////////////////////////////////////////////////////////////
+
+func main() {
+	// Manage application state.
+	go func() {
+		state := &state{cachedAccessToken, cachedRefreshToken}
+		for {
+			select {
+			case msg := <-chans.reads:
+				log.Printf("Reading from state.")
+				log.Printf("Access Token: %s\n", state.accessToken)
+				log.Printf("Refresh Token: %s\n", state.refreshToken)
+				msg.sender <- *state
+			case msg := <-chans.writes:
+				fmt.Printf("Writing new state: %v\n", msg.state)
+				*state = msg.state
+			}
+		}
+	}()
+
+	// Listen to inbound requests.
+	fmt.Println("Listening on http://localhost:4242 ...")
+	log.Fatal(http.ListenAndServe(":4242", http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
+		if req.URL.Path == "/refresh-tokens" && req.Method == "POST" {
+			msg := readMsg{make(chan state)}
+			chans.reads <- msg
+			state := <-msg.sender
+			go scheduleTokenRefresh(0, state.refreshToken)
+			fmt.Fprintf(w, "Done.")
+
+		} else if req.URL.Path == "/set-tokens" && req.Method == "POST" {
+			// Parse
+			payload := &setTokensRequest{}
+			err := json.NewDecoder(req.Body).Decode(payload)
+			if err != nil {
+				log.Fatal("Could not decode the user's JSON request.", err)
+			}
+
+			// Update application state
+			msg := writeMsg{state{payload.AccessToken, payload.RefreshToken}}
+			chans.writes <- msg
+
+			// Refresh tokens
+			go scheduleTokenRefresh(payload.ExpiresIn, payload.RefreshToken)
+
+			// Ack
+			fmt.Fprintf(w, "Done.")
+		} else if req.URL.Path == "/state" && req.Method == "GET" {
+			// TODO(wpcarro): Ensure that this returns serialized state.
+			w.Header().Set("Content-type", "application/json")
+			msg := readMsg{make(chan state)}
+			chans.reads <- msg
+			state := <-msg.sender
+			payload, _ := json.Marshal(state)
+			fmt.Fprintf(w, "Application state: %s\n", bytes.NewBuffer(payload))
+		} else {
+			log.Printf("Unhandled request: %v\n", *req)
+		}
+	})))
+}
diff --git a/monzo_ynab/utils.go b/monzo_ynab/utils.go
index 8b7decb90a..9b8843f24a 100644
--- a/monzo_ynab/utils.go
+++ b/monzo_ynab/utils.go
@@ -7,3 +7,43 @@ func failOn(err error) {
 		log.Fatal(err)
 	}
 }
+
+// Make a simple GET request to `url`. Fail if anything returns an error. I'd
+// like to accumulate a library of these, so that I can write scrappy Go
+// quickly. For now, this function just returns the body of the response back as
+// a string.
+func simpleGet(url string, headers map[string]string, debug bool) string {
+	client := &http.Client{}
+	req, err := http.NewRequest("GET", url, nil)
+	if err != nil {
+		log.Fatal(err)
+	}
+	for k, v := range headers {
+		req.Header.Add(k, v)
+	}
+
+	res, err := client.Do(req)
+	if err != nil {
+		log.Fatal(err)
+	}
+	defer res.Body.Close()
+
+	if debug {
+		bytes, _ := httputil.DumpRequest(req, true)
+		log.Println(string(bytes))
+		bytes, _ = httputil.DumpResponse(res, true)
+		log.Println(string(bytes))
+	}
+
+	if res.StatusCode == http.StatusOK {
+		bytes, err := ioutil.ReadAll(res.Body)
+		if err != nil {
+			log.Fatal(err)
+		}
+		return string(bytes)
+	} else {
+		log.Println(res)
+		log.Fatalf("HTTP status code of response not OK: %v\n", res.StatusCode)
+		return ""
+	}
+}