goth icon indicating copy to clipboard operation
goth copied to clipboard

Add Instagram Basic Display API Provider

Open ianlopshire opened this issue 5 years ago • 4 comments

The existing Instagram API is deprecated and will be Sunset on June 29th, 2020.

The replacement is the Instagram Basic Display API. This is an entirely new OAuth provider.

One potential issue I see is the naming of the provider. We will want to keep the existing Instagram provider in place.

ianlopshire avatar May 01 '20 16:05 ianlopshire

Locally I need to connect to the new Instagram graph flow too. As documented here https://developers.facebook.com/docs/instagram-basic-display-api/getting-started

So I have duplicated the existing provider and made changes as follows. Note that the session.go already present will work out of the box with these changes.

  • Updated the scopes to use by default the user_profile and user_media.
  • Request a "long lived" token as soon as a "short lived" is returned
  • Altered the user values bound
  • ~Updated the refresh token calls. Note I've just completed this and needs to be tested. Refresh token requests can only be fulfilled on tokens older than 24 hours. If the changes below don't work, I will amend after testing ok.~

I also see the issue of the naming of the provider but adding this here in case it can help. I probably should fork & create a PR. Likely will if I can enough requests to do so. Thanks K

// Package instagram implements the OAuth2 protocol for authenticating users through Instagram.
// This package can be used as a reference implementation of an OAuth2 provider for Goth.
package instagram

import (
	"encoding/json"
	"fmt"
	"io"
	"net/http"
	"net/url"
	"time"

	"github.com/markbates/goth"
	"golang.org/x/oauth2"
)

var (
	authURL         = "https://api.instagram.com/oauth/authorize/"
	tokenURL        = "https://api.instagram.com/oauth/access_token"
	endPointProfile = "https://graph.instagram.com/me?fields=account_type,id,username"
	refreshTokenURL = "https://graph.instagram.com/refresh_access_token?grant_type=ig_refresh_token&access_token="
)

// New creates a new Instagram provider, and sets up important connection details.
// You should always call `instagram.New` to get a new Provider. Never try to craete
// one manually.
func New(clientKey, secret, callbackURL string, scopes ...string) *Provider {
	p := &Provider{
		ClientKey:    clientKey,
		Secret:       secret,
		CallbackURL:  callbackURL,
		providerName: "instagram",
	}
	p.config = newConfig(p, scopes)
	return p
}

// Provider is the implementation of `goth.Provider` for accessing Instagram
type Provider struct {
	ClientKey    string
	Secret       string
	CallbackURL  string
	UserAgent    string
	HTTPClient   *http.Client
	config       *oauth2.Config
	providerName string
}

// Name is the name used to retrieve this provider later.
func (p *Provider) Name() string {
	return p.providerName
}

// SetName is to update the name of the provider (needed in case of multiple providers of 1 type)
func (p *Provider) SetName(name string) {
	p.providerName = name
}

func (p *Provider) Client() *http.Client {
	return goth.HTTPClientWithFallBack(p.HTTPClient)
}

//Debug TODO
func (p *Provider) Debug(debug bool) {}

// BeginAuth asks Instagram for an authentication end-point.
func (p *Provider) BeginAuth(state string) (goth.Session, error) {
	url := p.config.AuthCodeURL(state)
	session := &Session{
		AuthURL: url,
	}
	return session, nil
}

// FetchUser will go to Instagram and access basic information about the user.
func (p *Provider) FetchUser(session goth.Session) (goth.User, error) {
	sess := session.(*Session)
	user := goth.User{
		AccessToken: sess.AccessToken,
		Provider:    p.Name(),
	}

	if user.AccessToken == "" {
		// data is not yet retrieved since accessToken is still empty
		return user, fmt.Errorf("%s cannot get user information without accessToken", p.providerName)
	}

	// Request a long lived access token now that we have a successful short lived token
	exchangeURI := fmt.Sprintf(
		"https://graph.instagram.com/access_token?grant_type=ig_exchange_token&client_secret=%s&access_token=%s",
		p.config.ClientSecret,
		url.QueryEscape(sess.AccessToken),
	)
	exchangeResponse, err := p.Client().Get(exchangeURI)
	if err != nil {
		return user, err
	}
	defer exchangeResponse.Body.Close()

	exchange := struct {
		AccessToken string `json:"access_token"`
		ExpiresIn   int64  `json:"expires_in"`
	}{}
	err = json.NewDecoder(exchangeResponse.Body).Decode(&exchange)
	if err != nil {
		return user, err
	}
	user.AccessToken = exchange.AccessToken
	user.ExpiresAt = time.Now().Add(time.Second * time.Duration(exchange.ExpiresIn))

	// Check if user_media scope is present and update the user fields to return
	uri := endPointProfile
	for _, scope := range p.config.Scopes {
		if scope == "user_media" {
			uri = uri + ",media_count"
			break
		}
	}
	response, err := p.Client().Get(uri + "&access_token=" + url.QueryEscape(user.AccessToken))
	if err != nil {
		return user, err
	}
	defer response.Body.Close()

	if response.StatusCode != http.StatusOK {
		return user, fmt.Errorf("%s responded with a %d trying to fetch user information", p.providerName, response.StatusCode)
	}

	err = userFromReader(response.Body, &user)
	return user, err
}

func userFromReader(reader io.Reader, user *goth.User) error {
	u := struct {
		AccountType string `json:"account_type"`
		ID          string `json:"id"`
		MediaCount  int    `json:"media_count"`
		UserName    string `json:"username"`
	}{}
	err := json.NewDecoder(reader).Decode(&u)
	if err != nil {
		return err
	}
	user.UserID = u.ID
	user.NickName = u.UserName
	return err
}

func newConfig(p *Provider, scopes []string) *oauth2.Config {
	c := &oauth2.Config{
		ClientID:     p.ClientKey,
		ClientSecret: p.Secret,
		RedirectURL:  p.CallbackURL,
		Endpoint: oauth2.Endpoint{
			AuthURL:  authURL,
			TokenURL: tokenURL,
		},
		Scopes: []string{
			"user_profile",
			"user_media",
		},
	}

	defaultScopes := map[string]struct{}{
		"user_profile": {},
		"user_media":   {},
	}

	for _, scope := range scopes {
		if _, exists := defaultScopes[scope]; !exists {
			c.Scopes = append(c.Scopes, scope)
		}
	}

	return c
}

// RefreshToken will refresh an instagram long lived token.
// Refer https://developers.facebook.com/docs/instagram-basic-display-api/guides/long-lived-access-tokens
func (p *Provider) RefreshToken(refreshToken string) (*oauth2.Token, error) {
	refreshResponse, err := p.Client().Get(refreshTokenURL + refreshToken)
	if err != nil {
		return nil, err
	}
	defer refreshResponse.Body.Close()

	refresh := struct {
		AccessToken string `json:"access_token"`
		ExpiresIn   int64  `json:"expires_in"`
	}{}
	err = json.NewDecoder(refreshResponse.Body).Decode(&refresh)
	if err != nil {
		return nil, err
	}

	Expires := time.Now().Add(time.Second * time.Duration(refresh.ExpiresIn))
	return &oauth2.Token{
		AccessToken: refresh.AccessToken,
		Expiry:      Expires,
	}, nil
}

// RefreshTokenAvailable refresh token
func (p *Provider) RefreshTokenAvailable() bool {
	return true
}

kylehqcom avatar Aug 04 '20 01:08 kylehqcom

@kylehqcom Don't know if you got any requests for the PR, but I'm blocked by the same issue, so I think a PR would be great!

yelskiy avatar Aug 26 '20 15:08 yelskiy

Hey @yelskiy, you're not completely blocked, you can use the code above and place into your project. eg

import (
    "github.com/markbates/goth"
    "gitlab.com/kylehqcom/***/***/lib/auth/provider/instagram"
)

instagramProvider := instagram.New(instagramClientID, instagramClientSecret, instagramCallbackURL)
goth.UseProviders(instagramProvider)

I am using this to connect all ok locally, not in production. Still not prime time as I needed for a side project.

kylehqcom avatar Aug 26 '20 17:08 kylehqcom

Thanks @kylehqcom! I should have mentioned in my comment that I am using the code you provided for my local development to keep moving forward.

yelskiy avatar Aug 26 '20 17:08 yelskiy