markbates / goth

Package goth provides a simple, clean, and idiomatic way to write authentication packages for Go web applications.
https://blog.gobuffalo.io/goth-needs-a-new-maintainer-626cd47ca37b
MIT License
5.42k stars 580 forks source link

Add Instagram Basic Display API Provider #331

Open ianlopshire opened 4 years ago

ianlopshire commented 4 years ago

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.

kylehqcom commented 4 years ago

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.

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
}
yelskiy commented 4 years ago

@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!

kylehqcom commented 4 years ago

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.

yelskiy commented 4 years ago

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.