Skip to content

Commit 45d0e8a

Browse files
authored
Merge pull request #98 from supabase/twitter-provider-jwt
Feature: Twitter OAuth🐦
2 parents a0717c2 + 1bd4e66 commit 45d0e8a

File tree

8 files changed

+475
-69
lines changed

8 files changed

+475
-69
lines changed

api/context.go

+27
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,8 @@ const (
2929
externalReferrerKey = contextKey("external_referrer")
3030
functionHooksKey = contextKey("function_hooks")
3131
adminUserKey = contextKey("admin_user")
32+
oauthTokenKey = contextKey("oauth_token") // for OAuth1.0, also known as request token
33+
oauthVerifierKey = contextKey("oauth_verifier")
3234
)
3335

3436
// withToken adds the JWT token to the context.
@@ -223,3 +225,28 @@ func getAdminUser(ctx context.Context) *models.User {
223225
}
224226
return obj.(*models.User)
225227
}
228+
229+
// withRequestToken adds the request token to the context
230+
func withRequestToken(ctx context.Context, token string) context.Context {
231+
return context.WithValue(ctx, oauthTokenKey, token)
232+
}
233+
234+
func getRequestToken(ctx context.Context) string {
235+
obj := ctx.Value(oauthTokenKey)
236+
if obj == nil {
237+
return ""
238+
}
239+
return obj.(string)
240+
}
241+
242+
func withOAuthVerifier(ctx context.Context, token string) context.Context {
243+
return context.WithValue(ctx, oauthVerifierKey, token)
244+
}
245+
246+
func getOAuthVerifier(ctx context.Context) string {
247+
obj := ctx.Value(oauthVerifierKey)
248+
if obj == nil {
249+
return ""
250+
}
251+
return obj.(string)
252+
}

api/external.go

+24-2
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import (
1111

1212
jwt "github.com/dgrijalva/jwt-go"
1313
"github.com/gofrs/uuid"
14+
"github.com/markbates/goth/gothic"
1415
"github.com/netlify/gotrue/api/provider"
1516
"github.com/netlify/gotrue/models"
1617
"github.com/netlify/gotrue/storage"
@@ -37,7 +38,7 @@ func (a *API) ExternalProviderRedirect(w http.ResponseWriter, r *http.Request) e
3738
providerType := r.URL.Query().Get("provider")
3839
scopes := r.URL.Query().Get("scopes")
3940

40-
provider, err := a.Provider(ctx, providerType, scopes)
41+
p, err := a.Provider(ctx, providerType, scopes)
4142
if err != nil {
4243
return badRequestError("Unsupported provider: %+v", err).WithInternalError(err)
4344
}
@@ -78,7 +79,18 @@ func (a *API) ExternalProviderRedirect(w http.ResponseWriter, r *http.Request) e
7879
return internalServerError("Error creating state").WithInternalError(err)
7980
}
8081

81-
http.Redirect(w, r, provider.AuthCodeURL(tokenString), http.StatusFound)
82+
var url string
83+
if twitterProvider, ok := p.(*provider.TwitterProvider); ok {
84+
url = twitterProvider.AuthCodeURL(tokenString)
85+
err := gothic.StoreInSession(providerType, twitterProvider.Marshal(), r, w)
86+
if err != nil {
87+
return internalServerError("Error storing request token in session").WithInternalError(err)
88+
}
89+
} else {
90+
url = p.AuthCodeURL(tokenString)
91+
}
92+
93+
http.Redirect(w, r, url, http.StatusFound)
8294
return nil
8395
}
8496

@@ -101,6 +113,14 @@ func (a *API) internalExternalProviderCallback(w http.ResponseWriter, r *http.Re
101113
return err
102114
}
103115
userData = samlUserData
116+
} else if providerType == "twitter" {
117+
// future OAuth1.0 providers will use this method
118+
oAuthResponseData, err := a.oAuth1Callback(ctx, r, providerType)
119+
if err != nil {
120+
return err
121+
}
122+
userData = oAuthResponseData.userData
123+
providerToken = oAuthResponseData.token
104124
} else {
105125
oAuthResponseData, err := a.oAuthCallback(ctx, r, providerType)
106126
if err != nil {
@@ -316,6 +336,8 @@ func (a *API) Provider(ctx context.Context, name string, scopes string) (provide
316336
return provider.NewGoogleProvider(config.External.Google, scopes)
317337
case "facebook":
318338
return provider.NewFacebookProvider(config.External.Facebook, scopes)
339+
case "twitter":
340+
return provider.NewTwitterProvider(config.External.Twitter, scopes)
319341
case "azure":
320342
return provider.NewAzureProvider(config.External.Azure, scopes)
321343
case "saml":

api/external_oauth.go

+48-2
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,15 @@ import (
44
"context"
55
"net/http"
66

7+
"github.com/markbates/goth/gothic"
8+
"github.com/mrjones/oauth"
79
"github.com/netlify/gotrue/api/provider"
810
"github.com/sirupsen/logrus"
911
)
1012

1113
type OAuthProviderData struct {
1214
userData *provider.UserProvidedData
13-
token string
15+
token string
1416
}
1517

1618
// loadOAuthState parses the `state` query parameter as a JWS payload,
@@ -22,6 +24,14 @@ func (a *API) loadOAuthState(w http.ResponseWriter, r *http.Request) (context.Co
2224
}
2325

2426
ctx := r.Context()
27+
oauthToken := r.URL.Query().Get("oauth_token")
28+
if oauthToken != "" {
29+
ctx = withRequestToken(ctx, oauthToken)
30+
}
31+
oauthVerifier := r.URL.Query().Get("oauth_verifier")
32+
if oauthVerifier != "" {
33+
ctx = withOAuthVerifier(ctx, oauthVerifier)
34+
}
2535
return a.loadExternalState(ctx, state)
2636
}
2737

@@ -61,8 +71,44 @@ func (a *API) oAuthCallback(ctx context.Context, r *http.Request, providerType s
6171

6272
return &OAuthProviderData{
6373
userData: userData,
64-
token: token.AccessToken,
74+
token: token.AccessToken,
75+
}, nil
76+
}
77+
78+
func (a *API) oAuth1Callback(ctx context.Context, r *http.Request, providerType string) (*OAuthProviderData, error) {
79+
oAuthProvider, err := a.OAuthProvider(ctx, providerType)
80+
if err != nil {
81+
return nil, badRequestError("Unsupported provider: %+v", err).WithInternalError(err)
82+
}
83+
value, err := gothic.GetFromSession(providerType, r)
84+
if err != nil {
85+
return &OAuthProviderData{}, err
86+
}
87+
oauthVerifier := getOAuthVerifier(ctx)
88+
var accessToken *oauth.AccessToken
89+
var userData *provider.UserProvidedData
90+
if twitterProvider, ok := oAuthProvider.(*provider.TwitterProvider); ok {
91+
requestToken, err := twitterProvider.Unmarshal(value)
92+
if err != nil {
93+
return &OAuthProviderData{}, err
94+
}
95+
twitterProvider.OauthVerifier = oauthVerifier
96+
accessToken, err = twitterProvider.Consumer.AuthorizeToken(requestToken, oauthVerifier)
97+
if err != nil {
98+
return nil, internalServerError("Unable to retrieve access token").WithInternalError(err)
99+
}
100+
userData, err = twitterProvider.FetchUserData(ctx, accessToken)
101+
}
102+
103+
if err != nil {
104+
return nil, internalServerError("Error getting user email from external provider").WithInternalError(err)
105+
}
106+
107+
return &OAuthProviderData{
108+
userData: userData,
109+
token: accessToken.Token,
65110
}, nil
111+
66112
}
67113

68114
func (a *API) OAuthProvider(ctx context.Context, name string) (provider.OAuthProvider, error) {

api/provider/twitter.go

+125
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
package provider
2+
3+
import (
4+
"bytes"
5+
"context"
6+
"encoding/json"
7+
"fmt"
8+
"io/ioutil"
9+
"net/http"
10+
"strings"
11+
12+
"github.com/mrjones/oauth"
13+
"github.com/netlify/gotrue/conf"
14+
"golang.org/x/oauth2"
15+
)
16+
17+
const (
18+
requestURL = "https://api.twitter.com/oauth/request_token"
19+
authorizeURL = "https://api.twitter.com/oauth/authorize"
20+
tokenURL = "https://api.twitter.com/oauth/access_token"
21+
endpointProfile = "https://api.twitter.com/1.1/account/verify_credentials.json"
22+
)
23+
24+
type TwitterProvider struct {
25+
ClientKey string
26+
Secret string
27+
CallbackURL string
28+
AuthURL string
29+
RequestToken *oauth.RequestToken
30+
OauthVerifier string
31+
Consumer *oauth.Consumer
32+
}
33+
34+
type twitterUser struct {
35+
Name string `json:"name"`
36+
AvatarURL string `json:"profile_image_url"`
37+
Email string `json:"email"`
38+
}
39+
40+
func NewTwitterProvider(ext conf.OAuthProviderConfiguration, scopes string) (OAuthProvider, error) {
41+
p := &TwitterProvider{
42+
ClientKey: ext.ClientID,
43+
Secret: ext.Secret,
44+
CallbackURL: ext.RedirectURI,
45+
}
46+
p.Consumer = newConsumer(p)
47+
return p, nil
48+
}
49+
50+
func (t TwitterProvider) GetOAuthToken(_ string) (*oauth2.Token, error) {
51+
// stub method for OAuthProvider interface, unused in OAuth1.0 protocol
52+
return &oauth2.Token{}, nil
53+
}
54+
55+
func (t TwitterProvider) GetUserData(ctx context.Context, tok *oauth2.Token) (*UserProvidedData, error) {
56+
// stub method for OAuthProvider interface, unused in OAuth1.0 protocol
57+
return &UserProvidedData{}, nil
58+
}
59+
60+
func (t TwitterProvider) FetchUserData(ctx context.Context, tok *oauth.AccessToken) (*UserProvidedData, error) {
61+
var u twitterUser
62+
resp, err := t.Consumer.Get(
63+
endpointProfile,
64+
map[string]string{"include_entities": "false", "skip_status": "true", "include_email": "true"},
65+
tok)
66+
if err != nil {
67+
return &UserProvidedData{}, err
68+
}
69+
defer resp.Body.Close()
70+
if resp.StatusCode != http.StatusOK {
71+
return &UserProvidedData{}, fmt.Errorf("Twitter responded with a %d trying to fetch user information", resp.StatusCode)
72+
}
73+
bits, err := ioutil.ReadAll(resp.Body)
74+
if err != nil {
75+
return &UserProvidedData{}, nil
76+
}
77+
err = json.NewDecoder(bytes.NewReader(bits)).Decode(&u)
78+
79+
data := &UserProvidedData{
80+
Metadata: map[string]string{
81+
nameKey: u.Name,
82+
avatarURLKey: u.AvatarURL,
83+
},
84+
Emails: []Email{{
85+
Email: u.Email,
86+
Verified: true,
87+
Primary: true,
88+
}},
89+
}
90+
return data, nil
91+
}
92+
93+
func (t *TwitterProvider) AuthCodeURL(state string, args ...oauth2.AuthCodeOption) string {
94+
// we do nothing with the state here as the state is passed in the requestURL step
95+
requestToken, url, err := t.Consumer.GetRequestTokenAndUrl(t.CallbackURL + "?state=" + state)
96+
if err != nil {
97+
return ""
98+
}
99+
t.RequestToken = requestToken
100+
t.AuthURL = url
101+
return t.AuthURL
102+
}
103+
104+
func newConsumer(provider *TwitterProvider) *oauth.Consumer {
105+
c := oauth.NewConsumer(
106+
provider.ClientKey,
107+
provider.Secret,
108+
oauth.ServiceProvider{
109+
RequestTokenUrl: requestURL,
110+
AuthorizeTokenUrl: authorizeURL,
111+
AccessTokenUrl: tokenURL,
112+
})
113+
return c
114+
}
115+
116+
func (t TwitterProvider) Marshal() string {
117+
b, _ := json.Marshal(t.RequestToken)
118+
return string(b)
119+
}
120+
121+
func (t TwitterProvider) Unmarshal(data string) (*oauth.RequestToken, error) {
122+
requestToken := &oauth.RequestToken{}
123+
err := json.NewDecoder(strings.NewReader(data)).Decode(requestToken)
124+
return requestToken, err
125+
}

api/settings.go

+2
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ type ProviderSettings struct {
88
GitLab bool `json:"gitlab"`
99
Google bool `json:"google"`
1010
Facebook bool `json:"facebook"`
11+
Twitter bool `json:"twitter"`
1112
Azure bool `json:"azure"`
1213
Email bool `json:"email"`
1314
SAML bool `json:"saml"`
@@ -34,6 +35,7 @@ func (a *API) Settings(w http.ResponseWriter, r *http.Request) error {
3435
GitLab: config.External.Gitlab.Enabled,
3536
Google: config.External.Google.Enabled,
3637
Facebook: config.External.Facebook.Enabled,
38+
Twitter: config.External.Twitter.Enabled,
3739
Azure: config.External.Azure.Enabled,
3840
Email: !config.External.Email.Disabled,
3941
SAML: config.External.Saml.Enabled,

conf/configuration.go

+3-2
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,7 @@ type ProviderConfiguration struct {
8585
Gitlab OAuthProviderConfiguration `json:"gitlab"`
8686
Google OAuthProviderConfiguration `json:"google"`
8787
Facebook OAuthProviderConfiguration `json:"facebook"`
88+
Twitter OAuthProviderConfiguration `json:"twitter"`
8889
Azure OAuthProviderConfiguration `json:"azure"`
8990
Email EmailProviderConfiguration `json:"email"`
9091
Saml SamlProviderConfiguration `json:"saml"`
@@ -109,8 +110,8 @@ type MailerConfiguration struct {
109110

110111
// Configuration holds all the per-instance configuration.
111112
type Configuration struct {
112-
SiteURL string `json:"site_url" split_words:"true" required:"true"`
113-
URIAllowList []string `json:"uri_allow_list" split_words:"true"`
113+
SiteURL string `json:"site_url" split_words:"true" required:"true"`
114+
URIAllowList []string `json:"uri_allow_list" split_words:"true"`
114115
PasswordMinLength int `json:"password_min_length" default:"6"`
115116
JWT JWTConfiguration `json:"jwt"`
116117
SMTP SMTPConfiguration `json:"smtp"`

0 commit comments

Comments
 (0)