diff options
-rw-r--r-- | monzo_ynab/main.go | 149 | ||||
-rw-r--r-- | monzo_ynab/requests.txt | 22 | ||||
-rw-r--r-- | monzo_ynab/tokens.go | 183 | ||||
-rw-r--r-- | monzo_ynab/utils.go | 40 |
4 files changed, 342 insertions, 52 deletions
diff --git a/monzo_ynab/main.go b/monzo_ynab/main.go index 07f3af9a1c81..1709ffda786e 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 4e35e6b6c8dd..5698dfee3763 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 000000000000..47e991e56132 --- /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 8b7decb90a3f..9b8843f24a77 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 "" + } +} |