I asked ChatGPT to write me a program

It almost worked!

This post contains a lot of code, presented as close as possible to the code ChatGPT gave me. I’m including it here so people can see how good or bad they think it is. Where necessary I modified the code to make it work, but it’s as close as possible.

All this code makes the post look longer than it is. If you’re not interested in the code then you can just skip over it and just read my words :-)

The problem

I wanted to write a program to talk to the Fitbit API so I could get data out of it (eg record of heart rate while I was exercising). It’s not too hard to do; you just need an authorization token and a curl command

A quick hack’n’slash of code and I came up with something like:

#!/bin/ksh -p

d=${D:-$(date +"%Y-%m-%d")}

case $1 in
  cal*) T=calories; D=1min; I=activities-calories-intraday ;;
     *) T=heart ; D=1sec; I=activities-heart-intraday ;;
esac

URL=https://api.fitbit.com/1/user/-/activities/$T/date/$d/$d/$D/time/00:00/23:59.json

A="Bearer ....."

curl -H "Authorization: $A" $URL | jq -r '."'$I'".dataset[]|(.value|tostring)+","+.time'  | less

The problem is in getting the bearer token. This is oauth2 authentication. It could be done in shell script, but it’d be messy.

The proposed solution

Instead of doing this in shell script, I decided to do it in GoLang. I’d previously done oauth2 in perl (ugh, over a decade ago!) to talk to Google Calendar but I’m trying to use Go for stuff these days.

So I did some searching and Go has oauth2 libraries (naturally). It didn’t seem too hard. There were even examples of code talking to the Fitbit API. But I couldn’t find any code to explain how to use the refresh token. This is essential for automated use (e.g. from a cron job).

Let’s ask ChatGPT

So I thought I’d ask AI to help. Just the generic chatgpt.com, without a login; the bare bones generic free offering. If it worked then I’d be very impressed. If it didn’t then at least my biases would be confirmed :-)

Spoiler: it’s somewhere in between

I wasn’t going to “vibe code”, but use what I already knew to modify/fix what the AI gave me.

Getting the oauth2 token

So I asked it “I need a program in GoLang to perform oauth2 authentication from a CLI”.

And it provided a program that did this. I noticed it was designed for talking to Google endpoints, so I followed up with “what about for fitbit”. The resulting code was almost correct! Naturally it had places for hard-coded credentials, and the redirect URL wasn’t quite right; but these are things easily fixed.

After a little bit of cleanup (and me adding a quick kludge so I could make this a function) the code looked a bit like this:


package main

import (
	"context"
	"fmt"
	"log"
	"net/http"
	"encoding/json"

	"golang.org/x/oauth2"
)

var (
	// Replace with your Fitbit app's credentials
	clientID     = "XXXXXX"
	clientSecret = "XXXXXX"
	redirectURL  = "http://localhost:8080"
)

// OAuth2 configuration for Fitbit
var oauth2Config = oauth2.Config{
	ClientID:     clientID,
	ClientSecret: clientSecret,
	RedirectURL:  redirectURL,
	Scopes:       []string{"activity", "profile"}, // Adjust scopes as needed
	Endpoint: oauth2.Endpoint{
		AuthURL:  "https://www.fitbit.com/oauth2/authorize",
		TokenURL: "https://api.fitbit.com/oauth2/token",
	},
}

var oauth2StateString = "random_state_string" // A random string for security purposes

// Function to handle the OAuth2 flow
func getOAuth2Token() (*oauth2.Token, error) {
	channel := make(chan string)

	// Step 1: Generate the URL for the user to authenticate
	authURL := oauth2Config.AuthCodeURL(oauth2StateString, oauth2.AccessTypeOffline)
	fmt.Println("Go to the following URL and authorize the application:", authURL)

	// Step 2: Set up the web server to handle the redirect and capture the authorization code
	http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
		code := r.URL.Query().Get("code")
		if code == "" {
			channel <- "err_code"
			http.Error(w, "Code is missing", http.StatusBadRequest)
			return
		}

		// Step 3: Exchange the code for an access token
		token, err := oauth2Config.Exchange(context.Background(), code)
		if err != nil {
			channel <- "err_exchange"
			http.Error(w, fmt.Sprintf("Failed to exchange token: %v", err), http.StatusInternalServerError)
			return
		}

		// Step 4: Output the access token
		fmt.Fprintf(w, "Access token: %s\n", token.AccessToken)
		fmt.Printf("Access token: %s\n", token.AccessToken)
		channel <- "done"
	})

	// Start an HTTP server to handle the callback
	go http.ListenAndServe(":8080", nil)

	// Wait for the user to visit the URL and authenticate
	// time.Sleep(2 * time.Second)
	msg := <- channel
	fmt.Println(msg)

	return nil, nil
}

func main() {
	// Start OAuth2 token acquisition process
	_, err := getOAuth2Token()
	if err != nil {
		log.Fatalf("Error during OAuth2 process: %v", err)
	}
}

Now this actually worked! There’s a couple of places where it smells, though.

  • Hard coding the FitBit endpoints instead of using golang.org/x/oauth2/fitbit
  • No handling of the state string

But it worked. It got me a bearer token that I could use. That could easily be used to persist the returned data so it could be used later

How to use this?

So the next question was “how do I use this to get data?” and it returned some simple code. Again it worked. I’m not sure I had to mangle anything here.

package main

import (
	"fmt"
	"log"
	"net/http"
	"io/ioutil"
)

func getUserProfile(accessToken string) {
	// Fitbit API endpoint for user profile
	url := "https://api.fitbit.com/1/user/-/profile.json"
	req, err := http.NewRequest("GET", url, nil)
	if err != nil {
		log.Fatalf("Error creating request: %v", err)
	}

	// Set the Authorization header with the access token
	req.Header.Set("Authorization", "Bearer "+accessToken)

	// Perform the request
	client := &http.Client{}
	resp, err := client.Do(req)
	if err != nil {
		log.Fatalf("Error making request: %v", err)
	}
	defer resp.Body.Close()

	// Read and print the response body
	body, err := ioutil.ReadAll(resp.Body)
	if err != nil {
		log.Fatalf("Error reading response: %v", err)
	}

	// Print the user profile
	fmt.Println(string(body))
}

func main() {
	// Replace this with the actual token you obtained during OAuth
	accessToken := "XXXXX"
	getUserProfile(accessToken)
}

There’s not really a lot to say there; it’s a simple enough http client.

Refresh token

But nowhere were we handling refresh tokens. So I then asked “How do we handle refresh tokens”

More code. This time it didn’t work properly because it was missing the basicAuth token (I had to add that). It’s also unnecessarily sending the client secret.

package main

import (
	"fmt"
	"log"
	"net/http"
	"net/url"
	"strings"
	"time"
	"encoding/json"

	"golang.org/x/oauth2"
)

var (
	clientID     = "XXXXX"
        clientSecret = "XXXXX"
	// Replace with your refresh token obtained during initial OAuth
	refreshToken = "XXXXX"
)

// Fitbit OAuth2 token endpoint
var tokenURL = "https://api.fitbit.com/oauth2/token"

// Function to refresh the access token
func refreshAccessToken(refreshToken string) (*oauth2.Token, error) {
	// Prepare the data for the token request
	data := url.Values{}
	data.Set("grant_type", "refresh_token")
	data.Set("refresh_token", refreshToken)
	data.Set("client_id", clientID)
	data.Set("client_secret", clientSecret)

	// Send the POST request to the token URL
	req, err := http.NewRequest("POST", tokenURL, strings.NewReader(data.Encode()))
	if err != nil {
		return nil, fmt.Errorf("Error creating request: %v", err)
	}
	req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
	req.SetBasicAuth(clientID,clientSecret)

	// Perform the request
	client := &http.Client{}
	resp, err := client.Do(req)
	if err != nil {
		return nil, fmt.Errorf("Error making request: %v", err)
	}
	defer resp.Body.Close()

	// If the request failed, return an error
	if resp.StatusCode != http.StatusOK {
		return nil, fmt.Errorf("Failed to refresh token, status code: %d", resp.StatusCode)
	}

	// Parse the response to get the new access token and refresh token
	var tokenResponse struct {
		AccessToken  string `json:"access_token"`
		RefreshToken string `json:"refresh_token"`
		TokenType    string `json:"token_type"`
		ExpiresIn    int    `json:"expires_in"`
	}

	// Decode the JSON response
	if err := json.NewDecoder(resp.Body).Decode(&tokenResponse); err != nil {
		return nil, fmt.Errorf("Error decoding response: %v", err)
	}

	// Return the new access token
	fmt.Printf("New Access Token: %s\n", tokenResponse.AccessToken)
	fmt.Printf("New Refresh Token: %s\n", tokenResponse.RefreshToken)

	// Optionally, update the stored refresh token, if Fitbit returns a new one
	// Update the refreshToken variable if necessary

	// Return the new token as an oauth2.Token
	return &oauth2.Token{
		AccessToken:  tokenResponse.AccessToken,
		RefreshToken: tokenResponse.RefreshToken,
		TokenType:    tokenResponse.TokenType,
		Expiry:       time.Now().Add(time.Duration(tokenResponse.ExpiresIn) * time.Second),
	}, nil
}

func main() {
	// Call the refresh function
	token, err := refreshAccessToken(refreshToken)
	if err != nil {
		log.Fatalf("Error refreshing access token: %v", err)
	}

	// Print the new access token
	fmt.Printf("Refreshed Access Token: %s\n", token.AccessToken)
}

It worked. But something is rotten in the state of Denmark.

Basically this is talking raw https to the Fitbit endpoints. It’s sending raw data, parsing JSON, doing everything itself. It’s not using the provided oauth2 libraries (except at the end, to create a token object).

This can’t be the right way of doing it.

The right(?) way

It turns out the reason I was having trouble finding a good answer, and likely the reason ChatGPT couldn’t do it, was because it’s not obvious. There’s a long thread on this going back 10 years.

It appears the original developers didn’t even think the refresh token could change ‘cos it’s optional in the spec and Google doesn’t seem to change it. So they embedded a refresh process inside the https communication flow and used the refresh token to generate a new AccessToken, but didn’t expose the new tokens to the caller!

Many of the solutions presented in that thread involve creating wrapper types with callback functions so the new tokens can be persisted transparently from the main code.

I decided to use a wrapper function instead, since I was going to build this type of abstraction anyway.

func call_fitbit_api(url string) []byte {
        // Get the oauth2 config and token
        conf := fitbit_config()
        token := fitbit_token()

        client := conf.Client(context.Background(), token)
        response, err := client.Get(url)
        check_or_die(err)

        check_refresh_token(client)

        body, err := io.ReadAll(response.Body)
        check_or_die(err)

        return body
}

And there’s a bit of magic where we delve into the client structure to find the current oauth2 token.

func check_refresh_token(client *http.Client) {
        // There's gotta be a better way to get the new token!
        nt, err := client.Transport.(*oauth2.Transport).Source.Token()
        check_or_die(err)

        if nt.AccessToken != configuration.AccessToken ||
           nt.RefreshToken != configuration.RefreshToken ||
           nt.Expiry != configuration.Expiry {
                configuration.AccessToken = nt.AccessToken
                configuration.RefreshToken = nt.RefreshToken
                configuration.Expiry = nt.Expiry
                save_config()
                fmt.Fprintf(os.Stderr,"Token refreshed")
           }
}

Digging so deep into a structure also smells a little, but at least it’s using the documented endpoints and I can’t find a better way. (If you know of one, let me know in the comments, please!)

Final code

The final code is on github.

Do note that this code doesn’t protect the token at rest; it just writes it out in the clear to the filesystem. This isn’t meant to be enterprise secure levels of code (you’d encrypt using the enterprise tools, persist in a store somewhere), but for my home use.

Now my shell script can become a lot simpler:

BASE=https://api.fitbit.com/1/user/-

get_activity()
{
  fitbit_get $BASE/activities/$1/date/$day/$day/$2/time/00:00/23:59.json | jq -r '."'$3'".dataset[]|(.value|tostring)+","+.time'  | less
}

case $1 in
   heart|"") get_activity heart 1sec activities-heart-intraday ;;
        cal) get_activity calories 1min activities-calories-intraday ;;
          *) echo Get what...
esac

Summary

So this both impressed and scared me.

It provided commented code that almost worked.

It’s the sort of code I might have hacked up 20 years ago (who am I kidding; I did hack up this sort of code 20 years ago!). And it would work. Well, mostly; there’s a potential race condition in the original refresh method depending on how it’s called; eg token.Valid() could return true but a second later the token expires so the GET request would fail.

But it’s bad code. It’s the sort of code that would worry me if it made it into an enterprise code base.

I’m not sure many AppSec tools would pick up on this either, since it’s a code quality issue and not a code security issue. I know there are code quality tools out there, but there are arguments that these don’t belong in AppSec but in AppDev. I also don’t know if they would pick up on this; the refresh code was OK as code when looking at it as a http client and JSON parser; it was just the wrong approach to the problem.

All this tells me that ChatGPT won’t replace good developers. We need them to apply the sniff test to determine if generated code is of a sufficient quality and where the approach taken by the generator is the right one.

That takes skill and experience.

I kinda wonder whether a programming copilot would have done better, but I’m not sure. This feels like a training data issue, and I couldn’t find good answers through regular searching.