Skip to content

Commit

Permalink
refactor accounts service
Browse files Browse the repository at this point in the history
  • Loading branch information
brianstrauch committed Jun 13, 2021
1 parent 860c92e commit 53f2f9f
Show file tree
Hide file tree
Showing 8 changed files with 272 additions and 229 deletions.
170 changes: 170 additions & 0 deletions accounts.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
package spotify

import (
secure "crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/binary"
"encoding/hex"
"encoding/json"
"io"
"math/rand"
"net/http"
"net/url"
"strings"
)

const accountsBaseURL = "https://accounts.spotify.com"

const (
// Gives write access to user-provided images.
ScopeUGCImageUpload = "ugc-image-upload"
// Gives read access to a user’s recently played tracks.
ScopeUserReadRecentlyPlayed = "user-read-recently-played"
// Gives read access to a user's top artists and tracks.
ScopeUserTopRead = "user-top-read"
// Gives read access to a user’s playback position in a content.
ScopeUserReadPlaybackPosition = "user-read-playback-position"
// Gives read access to a user’s player state.
ScopeUserReadPlaybackState = "user-read-playback-state"
// Gives write access to a user’s playback state.
ScopeUserModifyPlaybackState = "user-modify-playback-state"
// Gives read access to a user’s currently playing content.
ScopeUserReadCurrentlyPlaying = "user-read-currently-playing"
// Gives write access to a user's public playlists.
ScopePlaylistModifyPublic = "playlist-modify-public"
// Gives write access to a user's private playlists.
ScopePlaylistModifyPrivate = "playlist-modify-private"
// Includes collaborative playlists when requesting a user's playlists.
ScopePlaylistReadCollaborative = "playlist-read-collaborative"
// Gives write/delete access to the list of artists and other users that the user follows.
ScopeUserFollowModify = "user-follow-modify"
// Gives read access to the list of artists and other users that the user follows.
ScopeUserFollowRead = "user-follow-read"
// Gives write/delete access to a user's "Your Music" library.
ScopeUserLibraryModify = "user-library-modify"
// Gives read access to a user's library.
ScopeUserLibraryRead = "user-library-read"
// Gives read access to user’s email address.
ScopeUserReadEmail = "user-read-email"
// Gives read access to user’s subscription details (type of user account).
ScopeUserReadPrivate = "user-read-private"
)

type Token struct {
AccessToken string `json:"access_token"`
TokenType string `json:"token_type"`
ExpiresIn int `json:"expires_in"`
RefreshToken string `json:"refresh_token"`
Scope string `json:"scope"`
}

// Generates a secure, random code verifier and code challenge for PKCE Authorization.
func CreateVerifierAndChallenge() (string, string, error) {
verifier, err := generateRandomVerifier()
if err != nil {
return "", "", err
}

challenge := calculateChallenge(verifier)

return string(verifier), challenge, nil
}

func generateRandomVerifier() ([]byte, error) {
seed, err := generateSecureSeed()
if err != nil {
return nil, err
}
rand.Seed(seed)

const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789_.-~"

verifier := make([]byte, 128)
for i := 0; i < len(verifier); i++ {
idx := rand.Intn(len(chars))
verifier[i] = chars[idx]
}

return verifier, nil
}

func generateSecureSeed() (int64, error) {
buf := make([]byte, 8)
_, err := secure.Read(buf)
if err != nil {
return 0, err
}

seed := int64(binary.BigEndian.Uint64(buf))
return seed, nil
}

func calculateChallenge(verifier []byte) string {
hash := sha256.Sum256(verifier)
challenge := base64.URLEncoding.EncodeToString(hash[:])
return strings.TrimRight(challenge, "=")
}

// Creates a random hex string used to mitigate cross-site request forgery attacks.
func GenerateRandomState() (string, error) {
buf := make([]byte, 7)
_, err := rand.Read(buf)
if err != nil {
return "", err
}

state := hex.EncodeToString(buf)
return state, nil
}

// Constructs the URI which users will be redirected to, to authorize the app.
func BuildAuthURI(clientID, redirectURI, challenge, state string, scopes ...string) string {
q := url.Values{}
q.Add("client_id", clientID)
q.Add("response_type", "code")
q.Add("redirect_uri", redirectURI)
q.Add("code_challenge_method", "S256")
q.Add("code_challenge", challenge)
q.Add("state", state)
q.Add("scope", strings.Join(scopes, " "))

return accountsBaseURL + "/authorize?" + q.Encode()
}

// Allows a user to exchange an authorization code for an access token.
func RequestToken(clientID, code, redirectURI, verifier string) (*Token, error) {
v := url.Values{}
v.Set("client_id", clientID)
v.Set("grant_type", "authorization_code")
v.Set("code", code)
v.Set("redirect_uri", redirectURI)
v.Set("code_verifier", verifier)
body := strings.NewReader(v.Encode())

return postToken(body)
}

// Allows a user to exchange a refresh token for an access token.
func RefreshToken(refreshToken, clientID string) (*Token, error) {
v := url.Values{}
v.Set("grant_type", "refresh_token")
v.Set("refresh_token", refreshToken)
v.Set("client_id", clientID)
body := strings.NewReader(v.Encode())

return postToken(body)
}

func postToken(body io.Reader) (*Token, error) {
res, err := http.Post(accountsBaseURL+"/api/token", "application/x-www-form-urlencoded", body)
if err != nil {
return nil, err
}
defer res.Body.Close()

token := new(Token)
err = json.NewDecoder(res.Body).Decode(token)

return token, err
}
119 changes: 0 additions & 119 deletions accounts_service.go

This file was deleted.

9 changes: 5 additions & 4 deletions accounts_service_test.go → accounts_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,8 @@ import (
"testing"
)

func TestStartProof(t *testing.T) {
verifier, challenge, err := StartProof()
func TestCreateVerifierAndChallenge(t *testing.T) {
verifier, challenge, err := CreateVerifierAndChallenge()
if err != nil {
t.Fatal(err)
}
Expand All @@ -27,16 +27,17 @@ func TestStartProof(t *testing.T) {

func TestBuildAuthURI(t *testing.T) {
var (
clientID = "client"
redirectURI = "http://localhost:1024"
challenge = "challenge"
state = "state"
scope = "user-modify-playback-state"
)

uri := BuildAuthURI(redirectURI, challenge, state, scope)
uri := BuildAuthURI(clientID, redirectURI, challenge, state, scope)

substrings := []string{
"client_id=" + ClientID,
"client_id=" + clientID,
"response_type=code",
"redirect_uri=" + url.QueryEscape(redirectURI),
"code_challenge_method=S256",
Expand Down
16 changes: 1 addition & 15 deletions api.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,20 +18,6 @@ type Error struct {
} `json:"error"`
}

type APIInterface interface {
GetPlayback() (*Playback, error)
Pause() error
Play(uris ...string) error
Queue(uri string) error
RemoveSavedTracks(ids ...string) error
Repeat(state string) error
SaveTracks(ids ...string) error
Search(q string, limit int) (*Paging, error)
Shuffle(state bool) error
SkipToNextTrack() error
SkipToPreviousTrack() error
}

type API struct {
token string
}
Expand All @@ -57,7 +43,7 @@ func (a *API) delete(endpoint string) error {
}

func (a *API) call(method string, endpoint string, body io.Reader, result interface{}) error {
req, err := http.NewRequest(method, BaseURL+ endpoint, body)
req, err := http.NewRequest(method, BaseURL+endpoint, body)
if err != nil {
return err
}
Expand Down
Loading

0 comments on commit 53f2f9f

Please sign in to comment.