Consume auth library

Consume the newly relocated auth package.

Additionally:
- Debugged error where JSON was properly decoding but not populating the
  refreshTokenResponse struct, so my application was signaling false positive
  messages about token refresh events.
- Logging more often and more data to help my troubleshooting
- Refreshing tokens as soon as the app starts just to be safe
- Clean up the code in general
This commit is contained in:
William Carroll 2020-02-09 17:52:40 +00:00
parent 44dca4a188
commit c83594fb5a

View file

@ -8,17 +8,19 @@ package main
//////////////////////////////////////////////////////////////////////////////// ////////////////////////////////////////////////////////////////////////////////
import ( import (
"bytes" "auth"
"encoding/json" "encoding/json"
"fmt" "fmt"
"io"
"kv"
"log" "log"
"net/http" "net/http"
"net/url" "net/url"
"os" "os"
"time"
"kv"
"os/signal" "os/signal"
"syscall" "syscall"
"time"
"utils"
) )
//////////////////////////////////////////////////////////////////////////////// ////////////////////////////////////////////////////////////////////////////////
@ -71,25 +73,38 @@ var chans = &channels{
} }
var ( var (
monzoClientId = os.Getenv("monzo_client_id") monzoClientId = os.Getenv("monzo_client_id")
monzoClientSecret = os.Getenv("monzo_client_secret") monzoClientSecret = os.Getenv("monzo_client_secret")
) )
//////////////////////////////////////////////////////////////////////////////// ////////////////////////////////////////////////////////////////////////////////
// Utils // Utils
//////////////////////////////////////////////////////////////////////////////// ////////////////////////////////////////////////////////////////////////////////
// Print the access and refresh tokens for debugging.
func logTokens(access string, refresh string) {
log.Printf("Access: %s\n", access)
log.Printf("Refresh: %s\n", refresh)
}
func (state *state) String() string {
return fmt.Sprintf("state{\n\taccessToken: \"%s\",\n\trefreshToken: \"%s\"\n}\n", state.accessToken, state.refreshToken)
}
// Schedule a token refresh for `expiresIn` seconds using the provided // Schedule a token refresh for `expiresIn` seconds using the provided
// `refreshToken`. This will update the application state with the access token // `refreshToken`. This will update the application state with the access token
// and schedule an additional token refresh for the newly acquired tokens. // and schedule an additional token refresh for the newly acquired tokens.
func scheduleTokenRefresh(expiresIn int, refreshToken string) { func scheduleTokenRefresh(expiresIn int, refreshToken string) {
duration := time.Second * time.Duration(expiresIn) duration := time.Second * time.Duration(expiresIn)
timestamp := time.Now().Local().Add(duration) timestamp := time.Now().Local().Add(duration)
// TODO(wpcarro): Consider adding a more human readable version that will
// log the number of hours, minutes, etc. until the next refresh.
log.Printf("Scheduling token refresh for %v\n", timestamp) log.Printf("Scheduling token refresh for %v\n", timestamp)
time.Sleep(duration) time.Sleep(duration)
log.Println("Refreshing tokens now...") log.Println("Refreshing tokens now...")
access, refresh := refreshTokens(refreshToken) access, refresh := refreshTokens(refreshToken)
log.Println("Successfully refreshed tokens.") log.Println("Successfully refreshed tokens.")
logTokens(access, refresh)
chans.writes <- writeMsg{state{access, refresh}} chans.writes <- writeMsg{state{access, refresh}}
} }
@ -104,22 +119,42 @@ func refreshTokens(refreshToken string) (string, string) {
"client_secret": {monzoClientSecret}, "client_secret": {monzoClientSecret},
"refresh_token": {refreshToken}, "refresh_token": {refreshToken},
}) })
if res.StatusCode != http.StatusOK {
// TODO(wpcarro): Considering panicking here.
utils.DebugResponse(res)
}
if err != nil { if err != nil {
log.Println(res) utils.DebugResponse(res)
log.Fatal("The request to Monzo to refresh our access token failed.", err) log.Fatal("The request to Monzo to refresh our access token failed.", err)
} }
defer res.Body.Close() defer res.Body.Close()
payload := &refreshTokenResponse{} payload := &refreshTokenResponse{}
err = json.NewDecoder(res.Body).Decode(payload) err = json.NewDecoder(res.Body).Decode(payload)
if err != nil { if err != nil {
log.Println(res)
log.Fatal("Could not decode the JSON response from Monzo.", err) log.Fatal("Could not decode the JSON response from Monzo.", err)
} }
go scheduleTokenRefresh(payload.ExpiresIn, payload.RefreshToken) go scheduleTokenRefresh(payload.ExpiresIn, payload.RefreshToken)
// Interestingly, JSON decoding into the refreshTokenResponse can success
// even if the decoder doesn't populate any of the fields in the
// refreshTokenResponse struct. From what I read, it isn't possible to make
// these fields as required using an annotation, so this guard must suffice
// for now.
if payload.AccessToken == "" || payload.RefreshToken == "" {
log.Fatal("JSON parsed correctly but failed to populate token fields.")
}
return payload.AccessToken, payload.RefreshToken return payload.AccessToken, payload.RefreshToken
} }
func persistTokens(access string, refresh string) {
log.Println("Persisting tokens...")
kv.Set("monzoAccessToken", access)
kv.Set("monzoRefreshToken", refresh)
log.Println("Successfully persisted tokens.")
}
// Listen for SIGINT and SIGTERM signals. When received, persist the access and // Listen for SIGINT and SIGTERM signals. When received, persist the access and
// refresh tokens and shutdown the server. // refresh tokens and shutdown the server.
func handleInterrupts() { func handleInterrupts() {
@ -132,14 +167,8 @@ func handleInterrupts() {
go func() { go func() {
sig := <-sigs sig := <-sigs
log.Printf("Received signal to shutdown. %v\n", sig) log.Printf("Received signal to shutdown. %v\n", sig)
// Persist existing tokens state := getState()
log.Println("Persisting existing credentials...") persistTokens(state.accessToken, state.refreshToken)
msg := readMsg{make(chan state)}
chans.reads <- msg
state := <-msg.sender
kv.Set("monzoAccessToken", state.accessToken)
kv.Set("monzoRefreshToken", state.refreshToken)
log.Println("Credentials persisted.")
done <- true done <- true
}() }()
@ -148,6 +177,13 @@ func handleInterrupts() {
os.Exit(0) os.Exit(0)
} }
// Return our application state.
func getState() state {
msg := readMsg{make(chan state)}
chans.reads <- msg
return <-msg.sender
}
//////////////////////////////////////////////////////////////////////////////// ////////////////////////////////////////////////////////////////////////////////
// Main // Main
//////////////////////////////////////////////////////////////////////////////// ////////////////////////////////////////////////////////////////////////////////
@ -158,11 +194,21 @@ func main() {
refreshToken := fmt.Sprintf("%v", kv.Get("monzoRefreshToken")) refreshToken := fmt.Sprintf("%v", kv.Get("monzoRefreshToken"))
log.Println("Attempting to retrieve cached credentials...") log.Println("Attempting to retrieve cached credentials...")
log.Printf("Access token: %s\n", accessToken) logTokens(accessToken, refreshToken)
log.Printf("Refresh token: %s\n", refreshToken)
if accessToken == "" || refreshToken == "" { if accessToken == "" || refreshToken == "" {
log.Fatal("Cannot start server without access or refresh tokens.") log.Println("Cached credentials are absent. Authorizing client...")
authCode := auth.GetAuthCode(monzoClientId)
tokens := auth.GetTokensFromAuthCode(authCode, monzoClientId, monzoClientSecret)
accessToken, refreshToken = tokens.AccessToken, tokens.RefreshToken
go persistTokens(accessToken, refreshToken)
go scheduleTokenRefresh(tokens.ExpiresIn, refreshToken)
} else {
// If we have tokens, they may be expiring soon. We don't know because
// we aren't storing the expiration timestamp in the state or in the
// store. Until we have that information, and to be safe, let's refresh
// the tokens.
scheduleTokenRefresh(0, refreshToken)
} }
// Gracefully shutdown. // Gracefully shutdown.
@ -174,54 +220,52 @@ func main() {
for { for {
select { select {
case msg := <-chans.reads: case msg := <-chans.reads:
log.Printf("Reading from state.") log.Println("Reading from state...")
log.Printf("Access Token: %s\n", state.accessToken) log.Println(state)
log.Printf("Refresh Token: %s\n", state.refreshToken)
msg.sender <- *state msg.sender <- *state
case msg := <-chans.writes: case msg := <-chans.writes:
fmt.Printf("Writing new state: %v\n", msg.state) log.Println("Writing to state.")
log.Printf("Old: %s\n", state)
*state = msg.state *state = msg.state
log.Printf("New: %s\n", state)
} }
} }
}() }()
// Listen to inbound requests. // Listen to inbound requests.
fmt.Println("Listening on http://localhost:4242 ...") fmt.Println("Listening on http://localhost:4242 ...")
log.Fatal(http.ListenAndServe(":4242", http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { log.Fatal(http.ListenAndServe(":4242",
if req.URL.Path == "/refresh-tokens" && req.Method == "POST" { http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
msg := readMsg{make(chan state)} if req.URL.Path == "/refresh-tokens" && req.Method == "POST" {
chans.reads <- msg state := getState()
state := <-msg.sender go scheduleTokenRefresh(0, state.refreshToken)
go scheduleTokenRefresh(0, state.refreshToken) fmt.Fprintf(w, "Done.")
fmt.Fprintf(w, "Done.")
} else if req.URL.Path == "/set-tokens" && req.Method == "POST" { } else if req.URL.Path == "/set-tokens" && req.Method == "POST" {
// Parse // Parse
payload := &setTokensRequest{} payload := &setTokensRequest{}
err := json.NewDecoder(req.Body).Decode(payload) err := json.NewDecoder(req.Body).Decode(payload)
if err != nil { if err != nil {
log.Fatal("Could not decode the user's JSON request.", err) 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")
state := getState()
payload, _ := json.Marshal(state)
io.WriteString(w, string(payload))
} else {
log.Printf("Unhandled request: %v\n", *req)
} }
})))
// 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)
}
})))
} }