Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Ensure clear example exists for how to use the client with existing access/refresh tokens #167

Open
strideynet opened this issue Aug 16, 2021 · 7 comments

Comments

@strideynet
Copy link
Collaborator

As it stands, there's no clear example for the following flow:

  1. User goes through oauth2 grant, access/refresh tokens are produced
  2. These tokens are persisted somewhere
  3. At a later date, the user makes a request to the service
  4. These tokens are then pulled out of persistence and used
@strideynet strideynet added this to the V2.1 milestone Aug 16, 2021
@strideynet strideynet removed this from the V2.1 milestone Jun 26, 2022
@campbelljlowman
Copy link

campbelljlowman commented Oct 24, 2022

Any update on this? I'm trying to figure out how to implement this flow in my application. I use a frontend to retrieve the user token and add it to a database. Then I want to read this token from the database and create a client using this token. Struggling to figure out a way to do this

Update: I figured this out. I pieced together some code from other examples:

        spotifyToken := "tokenString"
	token := &oauth2.Token{
		AccessToken: spotifyToken,
	}
	httpClient := spotifyauth.New().Client(ctx, token)
	client := spotify.New(httpClient)

spotifyToken is my token string which I read from the database. I have this code in a handler so I already has a ctx, but I believe you could just create a new background context like in some of the examples (context.Background())

The docs for an oauth2.Token have other fields for the token struct, one of which is a refresh token. I haven't looked into this yet but I'd guess it could handle refreshing for you

@timbrammer910
Copy link

timbrammer910 commented Nov 21, 2022

Yeah I'm struggling with this as well. I'm trying to just run a small app on a schedule, persisting client_id, client_secret, auth token, and refresh token as GitHub secrets. I'm setting the Expiry on the token to time.Now() when building a client:

`tok := &oauth2.Token{
AccessToken: "token string pulled from env",
RefreshToken: "token string pulled from env",
Expiry: time.Now(),
}

client := spotify.New(spotifyAuth.Client(context.Background(), tok))`

But receive oauth2: cannot fetch token: 400 Bad Request Response: {"error":"invalid_grant","error_description":"Invalid refresh token"}

Part of the trouble with this is that I'm struggling to understand the refresh token system in Spotify's API. It says things like "A new refresh token might be returned too." in the documentation, which isn't helpful. Can I keep generating new clients from a static set of access + refresh tokens? The OAuth2 docs seem to imply I can with this line "The token will auto-refresh as necessary"
here - https://pkg.go.dev/golang.org/x/oauth2#Config.Client

@strideynet
Copy link
Collaborator Author

Yeah I'm struggling with this as well. I'm trying to just run a small app on a schedule, persisting client_id, client_secret, auth token, and refresh token as GitHub secrets. I'm setting the Expiry on the token to time.Now() when building a client:

`tok := &oauth2.Token{ AccessToken: "token string pulled from env", RefreshToken: "token string pulled from env", Expiry: time.Now(), }

client := spotify.New(spotifyAuth.Client(context.Background(), tok))`

But receive oauth2: cannot fetch token: 400 Bad Request Response: {"error":"invalid_grant","error_description":"Invalid refresh token"}

Part of the trouble with this is that I'm struggling to understand the refresh token system in Spotify's API. It says things like "A new refresh token might be returned too." in the documentation, which isn't helpful. Can I keep generating new clients from a static set of access + refresh tokens? The OAuth2 docs seem to imply I can with this line "The token will auto-refresh as necessary" here - https://pkg.go.dev/golang.org/x/oauth2#Config.Client

If I recall correctly, they'll return a new refresh token when you refresh, so you'll need to come up with a way to write that back into the GitHub Action secret.

@timbrammer910
Copy link

It might, but that timeout isn't made available. I've reused a refresh token longer than the life of the initial access token that generated it.

I solved this by re-implementing the code described here - https://developer.spotify.com/documentation/general/guides/authorization/code-flow/ under "Request a refreshed Access Token" and just running it on each execution and collecting the authorization code it returns.

If there is a way to do this using this lib + the oauth2 lib, I couldn't figure it out.

@seankhliao
Copy link

fwiw i construct the oauth2.Config by hand and it seems to refresh properly if I explicitly set AuthStyle: oauth2.AuthStyleInHeader
Which is what i understood the spotify docs to require for the refresh flow https://developer.spotify.com/documentation/web-api/tutorials/code-flow#request-a-refreshed-access-token

example:

https://github.com/seankhliao/earbug/blob/60fd546c490cb2518efa5851eb2eec0ef14a3ad0/subcommands/serve/auth.go#L99-L108

@Ed-Mar
Copy link

Ed-Mar commented Aug 8, 2023

Simple example usage of "golang.org/x/oauth2" for Spotify

Instruction

  • Add http://localhost:8080/callback" to your Spotify's Applications "Redirect URL"
  • Run this code.
  • Navigate to http://localhost:8080/auth in your web browser.
  • Log In

Code

package main

import (
	"context"
	"golang.org/x/oauth2"
	"log"
	"net/http"
)

// tokenSource is a global variable that will be responsible for automatically refreshing the token when needed.
var tokenSource oauth2.TokenSource

// oauth2Config is the configuration for the OAuth2 flow, including the client ID, client secret, scopes, and endpoints.
var oauth2Config = oauth2.Config{
	RedirectURL:  "http://localhost:8080/callback",
	ClientID:     "your-client-id",
	ClientSecret: "your-client-secret",
	Scopes:       []string{"user-read-private", "user-read-email"},
	Endpoint: oauth2.Endpoint{
		AuthURL:  "https://accounts.spotify.com/authorize",
		TokenURL: "https://accounts.spotify.com/api/token"},
}

func main() {
	// Registering the handlers for the authentication process.
	http.HandleFunc("/auth", handleAuth)
	http.HandleFunc("/callback", handleCallback)
	// Starting the HTTP server on port 8080.
	log.Fatal(http.ListenAndServe(":8080", nil))
}

// handleAuth initiates the OAuth2 authorization flow by redirecting the user to the provider's consent page.
func handleAuth(w http.ResponseWriter, r *http.Request) {
	authURL := oauth2Config.AuthCodeURL("", oauth2.AccessTypeOffline)
	http.Redirect(w, r, authURL, http.StatusFound)
}

// requestAccessToken exchanges the authorization code for an access token.
func requestAccessToken(code string) (*oauth2.Token, error) {
	return oauth2Config.Exchange(context.Background(), code)
}

// handleCallback handles the callback from the OAuth2 provider. It extracts the authorization code from the request,
// exchanges it for an access token, and sets up a token source for automatic token refresh.
func handleCallback(w http.ResponseWriter, r *http.Request) {
	code := r.URL.Query().Get("code")
	if code == "" {
		http.Error(w, "Missing code", http.StatusBadRequest)
		return
	}

	token, err := requestAccessToken(code)
	log.Printf("Token: %v", token) // Logging the token (for debugging purposes; be cautious in production).
	if err != nil {
		http.Error(w, "Failed to exchange token", http.StatusInternalServerError)
		return
	}

	// Create a token source that will automatically refresh the token as needed.
	tokenSource = oauth2Config.TokenSource(context.Background(), token)
}

@nathan-hello
Copy link

nathan-hello commented Sep 29, 2024

I'll add my two cents because it took me a couple hours to turn the right dials and get it how I like it. I want my dependent route handlers to just call the NewSpotifyClient. Note that I dereference spotifyClient in NewSpotifyClient. I don't think that's necessary, I just like not returning pointers. Big thanks to @Ed-Mar for putting me on the right track. I'm not using the github.com/zmb3/spotify/v2/auth for anything but the scope string constants

package auth

import (
	"context"
	"fmt"
	"net/http"
	"os"
	"strconv"
	"time"

	"github.com/zmb3/spotify/v2"
	sa "github.com/zmb3/spotify/v2/auth"
	"golang.org/x/oauth2"
)

var oauth2Config = oauth2.Config{
	RedirectURL:  "http://localhost:3000/auth/spotify/callback",
	ClientID:     os.Getenv("SPOTIFY_CLIENT_ID"),
	ClientSecret: os.Getenv("SPOTIFY_SECRET"),
	Scopes:       []string{sa.ScopeUserReadEmail, sa.ScopePlaylistReadPrivate},
	Endpoint: oauth2.Endpoint{
		AuthURL:  "https://accounts.spotify.com/authorize",
		TokenURL: "https://accounts.spotify.com/api/token"},
}

func SpotifyLoginRedirect(w http.ResponseWriter, r *http.Request) {
	authURL := oauth2Config.AuthCodeURL("state", oauth2.AccessTypeOffline)
	http.Redirect(w, r, authURL, http.StatusFound)
}

func requestAccessToken(code string) (*oauth2.Token, error) {
	return oauth2Config.Exchange(context.Background(), code)
}

func SpotifyCallbackHandler(w http.ResponseWriter, r *http.Request) {
	code := r.URL.Query().Get("code")
	if code == "" {
		http.Error(w, "Missing code", http.StatusBadRequest)
		return
	}

	tokenReq, err := requestAccessToken(code)

	if err != nil {
		http.Error(w, "Failed to exchange token", http.StatusInternalServerError)
		return
	}

	token, err := oauth2Config.TokenSource(context.Background(), tokenReq).Token()
	if err != nil {
		http.Error(w, "Failed to exchange token", http.StatusInternalServerError)
		return
	}

	cookieSettings := &http.Cookie{
		Secure:   false, // Set to true in production with HTTPS
		HttpOnly: true,
		Path:     "/",
		SameSite: http.SameSiteLaxMode,
	}

	accessCookie := *cookieSettings
	accessCookie.Name = "spotify-access_token"
	accessCookie.Value = token.AccessToken
	accessCookie.Expires = token.Expiry
	http.SetCookie(w, &accessCookie)

	refreshCookie := *cookieSettings
	refreshCookie.Name = "spotify-refresh_token"
	refreshCookie.Value = token.RefreshToken
	http.SetCookie(w, &refreshCookie)

	expiryCookie := *cookieSettings
	expiryCookie.Name = "spotify-token_expiry"
	expiryCookie.Value = fmt.Sprintf("%d", token.Expiry.Unix())
	http.SetCookie(w, &expiryCookie)

	http.Redirect(w, r, "/", http.StatusFound)
}

func NewSpotifyClient(r *http.Request) (spotify.Client, error) {
	access, err := r.Cookie("spotify-access_token")
	if err != nil {
		return spotify.Client{}, err
	}
	refresh, err := r.Cookie("spotify-refresh_token")
	if err != nil {
		return spotify.Client{}, err
	}
	expiry, err := r.Cookie("spotify-token_expiry")
	if err != nil {
		return spotify.Client{}, err
	}

	expiryUnix, err := strconv.ParseInt(expiry.Value, 10, 64)
	if err != nil {
		return spotify.Client{}, err
	}
	expiryTime := time.Unix(expiryUnix, 0)

	oauthClient := oauth2.NewClient(
		r.Context(),
		oauth2.StaticTokenSource(
			&oauth2.Token{
				AccessToken:  access.Value,
				TokenType:    "Bearer",
				RefreshToken: refresh.Value,
				Expiry:       expiryTime},
		))
	spotClient := spotify.New(oauthClient)
	return *spotClient, nil
}

And then in the route handler (a bit contrived but once you have the client object, you should know what to do)...

import (
	"net/http"

	"github.com/nathan-hello/playlist-powertools/src/auth"
	"github.com/zmb3/spotify/v2"
)

func PlaylistCurate(w http.ResponseWriter, r *http.Request) {
	playlists := []*spotify.SimplePlaylistPage{}

	client, err := auth.NewSpotifyClient(r)
	if err != nil {
		http.Redirect(w, r, "/auth/spotify", http.StatusFound)
	}

	p1, err := client.CurrentUsersPlaylists(r.Context(), spotify.Offset(0))
	if err != nil {
		w.Write([]byte(err.Error()))
	}
	playlists = append(playlists, p1)

	for offset := 50; offset < int(p1.Total); offset += 50 {
		currentPage, err := client.CurrentUsersPlaylists(r.Context(), spotify.Offset(offset))
		if err != nil {
			w.Write([]byte(err.Error()))
		}
		playlists = append(playlists, currentPage)
	}

	_, err = w.Write([]byte("200"))
	if err != nil {
		panic(err)
	}

}

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

6 participants