Skip to content

Commit

Permalink
feat: add login with twitter
Browse files Browse the repository at this point in the history
Closes #517 #2117
  • Loading branch information
aeneasr committed Feb 28, 2024
1 parent 9710549 commit dbca630
Show file tree
Hide file tree
Showing 16 changed files with 368 additions and 107 deletions.
3 changes: 2 additions & 1 deletion embedx/config.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -436,7 +436,8 @@
"dingtalk",
"patreon",
"linkedin",
"lark"
"lark",
"twitter"
],
"examples": ["google"]
},
Expand Down
1 change: 1 addition & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -327,6 +327,7 @@ require (

require (
github.com/coreos/go-oidc/v3 v3.9.0
github.com/dghubble/oauth1 v0.7.2
github.com/lestrrat-go/jwx/v2 v2.0.19
)

Expand Down
2 changes: 2 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,8 @@ github.com/davidrjonas/semver-cli v0.0.0-20190116233701-ee19a9a0dda6/go.mod h1:+
github.com/decred/dcrd/crypto/blake256 v1.0.1/go.mod h1:2OfgNZ5wDpcsFmHmCK5gZTPcCXqlm2ArzUIkw9czNJo=
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.2.0 h1:8UrgZ3GkP4i/CLijOJx79Yu+etlyjdBU4sfcs2WYQMs=
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.2.0/go.mod h1:v57UDF4pDQJcEfFUCRop3lJL149eHGSe9Jvczhzjo/0=
github.com/dghubble/oauth1 v0.7.2 h1:pwcinOZy8z6XkNxvPmUDY52M7RDPxt0Xw1zgZ6Cl5JA=
github.com/dghubble/oauth1 v0.7.2/go.mod h1:9erQdIhqhOHG/7K9s/tgh9Ks/AfoyrO5mW/43Lu2+kE=
github.com/dgraph-io/ristretto v0.1.1 h1:6CWw5tJNgpegArSHpNHJKldNeq03FQCwYvfMVWajOK8=
github.com/dgraph-io/ristretto v0.1.1/go.mod h1:S1GPSBCYCIhmVNfcth17y2zZtQT6wzkzgwUve0VDWWA=
github.com/dgryski/go-farm v0.0.0-20190423205320-6a90982ecee2 h1:tdlZCpZ/P9DhczCTSixgIKmwPv6+wP5DGjqLYw5SUiA=
Expand Down
44 changes: 40 additions & 4 deletions identity/credentials_oidc.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,36 @@ type CredentialsOIDCProvider struct {
Organization string `json:"organization,omitempty"`
}

// swagger:ignore
type CredentialsOIDCEncryptedTokens struct {
RefreshToken string `json:"refresh_token,omitempty"`
IDToken string `json:"id_token,omitempty"`
AccessToken string `json:"access_token,omitempty"`
}

func (c *CredentialsOIDCEncryptedTokens) GetRefreshToken() string {
if c == nil {
return ""
}
return c.RefreshToken
}

func (c *CredentialsOIDCEncryptedTokens) GetAccessToken() string {
if c == nil {
return ""
}
return c.AccessToken
}

func (c *CredentialsOIDCEncryptedTokens) GetIDToken() string {
if c == nil {
return ""
}
return c.IDToken
}

// NewCredentialsOIDC creates a new OIDC credential.
func NewCredentialsOIDC(idToken, accessToken, refreshToken, provider, subject, organization string) (*Credentials, error) {
func NewCredentialsOIDC(tokens *CredentialsOIDCEncryptedTokens, provider, subject, organization string) (*Credentials, error) {
if provider == "" {
return nil, errors.New("received empty provider in oidc credentials")
}
Expand All @@ -48,9 +76,9 @@ func NewCredentialsOIDC(idToken, accessToken, refreshToken, provider, subject, o
{
Subject: subject,
Provider: provider,
InitialIDToken: idToken,
InitialAccessToken: accessToken,
InitialRefreshToken: refreshToken,
InitialIDToken: tokens.GetIDToken(),
InitialAccessToken: tokens.GetAccessToken(),
InitialRefreshToken: tokens.GetRefreshToken(),
Organization: organization,
}},
}); err != nil {
Expand All @@ -65,6 +93,14 @@ func NewCredentialsOIDC(idToken, accessToken, refreshToken, provider, subject, o
}, nil
}

func (c *CredentialsOIDCProvider) GetTokens() *CredentialsOIDCEncryptedTokens {
return &CredentialsOIDCEncryptedTokens{
RefreshToken: c.InitialRefreshToken,
IDToken: c.InitialIDToken,
AccessToken: c.InitialAccessToken,
}
}

func OIDCUniqueID(provider, subject string) string {
return fmt.Sprintf("%s:%s", provider, subject)
}
Expand Down
1 change: 1 addition & 0 deletions internal/client-go/go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5y
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e h1:bRhVy7zSSasaqNksaRZiA5EEI+Ei4I1nO5Jh72wfHlg=
golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4 h1:YUO/7uOKsKeq9UokNS62b8FYywz3ker1l1vDZRCRefw=
golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
Expand Down
22 changes: 18 additions & 4 deletions selfservice/strategy/oidc/provider.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,10 @@ package oidc

import (
"context"
"net/http"
"net/url"

"github.com/dghubble/oauth1"
"github.com/pkg/errors"

"github.com/ory/herodot"
Expand All @@ -18,12 +20,24 @@ import (

type Provider interface {
Config() *Configuration
}

type OAuth2Provider interface {
Provider
AuthCodeURLOptions(r ider) []oauth2.AuthCodeOption
OAuth2(ctx context.Context) (*oauth2.Config, error)
Claims(ctx context.Context, exchange *oauth2.Token, query url.Values) (*Claims, error)
AuthCodeURLOptions(r ider) []oauth2.AuthCodeOption
}

type TokenExchanger interface {
type OAuth1Provider interface {
Provider
OAuth1(ctx context.Context) *oauth1.Config
AuthURL(ctx context.Context, state string) (string, error)
Claims(ctx context.Context, token *oauth1.Token) (*Claims, error)
ExchangeToken(ctx context.Context, req *http.Request) (*oauth1.Token, error)
}

type OAuth2TokenExchanger interface {
Exchange(ctx context.Context, code string, opts ...oauth2.AuthCodeOption) (*oauth2.Token, error)
}

Expand Down Expand Up @@ -87,11 +101,11 @@ func (c *Claims) Validate() error {
// - `hd` (string): The `hd` parameter limits the login/registration process to a Google Organization, e.g. `mycollege.edu`.
// - `prompt` (string): The `prompt` specifies whether the Authorization Server prompts the End-User for reauthentication and consent, e.g. `select_account`.
// - `auth_type` (string): The `auth_type` parameter specifies the requested authentication features (as a comma-separated list), e.g. `reauthenticate`.
func UpstreamParameters(provider Provider, upstreamParameters map[string]string) []oauth2.AuthCodeOption {
func UpstreamParameters(upstreamParameters map[string]string) []oauth2.AuthCodeOption {
// validation of upstream parameters are already handled in the `oidc/.schema/link.schema.json` and `oidc/.schema/settings.schema.json` file.
// `upstreamParameters` will always only contain allowed parameters based on the configuration.

// we double check the parameters here to prevent any potential security issues.
// we double-check the parameters here to prevent any potential security issues.
allowedParameters := map[string]struct{}{
"login_hint": {},
"hd": {},
Expand Down
1 change: 1 addition & 0 deletions selfservice/strategy/oidc/provider_config.go
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,7 @@ var supportedProviders = map[string]func(config *Configuration, reg Dependencies
"linkedin": NewProviderLinkedIn,
"patreon": NewProviderPatreon,
"lark": NewProviderLark,
"twitter": NewProviderTwitter,
}

func (c ConfigurationCollection) Provider(id string, reg Dependencies) (Provider, error) {
Expand Down
2 changes: 1 addition & 1 deletion selfservice/strategy/oidc/provider_dingtalk.go
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ func (g *ProviderDingTalk) OAuth2(ctx context.Context) (*oauth2.Config, error) {
return g.oauth2(ctx), nil
}

func (g *ProviderDingTalk) Exchange(ctx context.Context, code string, opts ...oauth2.AuthCodeOption) (*oauth2.Token, error) {
func (g *ProviderDingTalk) ExchangeOAuth2Token(ctx context.Context, code string, opts ...oauth2.AuthCodeOption) (*oauth2.Token, error) {
conf, err := g.OAuth2(ctx)
if err != nil {
return nil, errors.WithStack(herodot.ErrInternalServerError.WithReasonf("%s", err))
Expand Down
164 changes: 164 additions & 0 deletions selfservice/strategy/oidc/provider_twitter.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
// Copyright © 2024 Ory Corp
// SPDX-License-Identifier: Apache-2.0

package oidc

import (
"context"
"encoding/json"
"fmt"
"net/http"

"github.com/dghubble/oauth1"
"github.com/dghubble/oauth1/twitter"
"github.com/pkg/errors"

"github.com/ory/herodot"
)

var _ Provider = (*ProviderTwitter)(nil)
var _ OAuth1Provider = (*ProviderTwitter)(nil)

const twitterUserInfoBase = "https://api.twitter.com/1.1/account/verify_credentials.json"
const twitterUserInfoWithEmail = twitterUserInfoBase + "?include_email=true"

type ProviderTwitter struct {
config *Configuration
reg Dependencies
}

func (p *ProviderTwitter) Config() *Configuration {
return p.config
}

func NewProviderTwitter(
config *Configuration,
reg Dependencies) Provider {
return &ProviderTwitter{
config: config,
reg: reg,
}
}

func (p *ProviderTwitter) ExchangeToken(ctx context.Context, req *http.Request) (*oauth1.Token, error) {
requestToken, verifier, err := oauth1.ParseAuthorizationCallback(req)
if err != nil {
return nil, err
}

accessToken, accessSecret, err := p.OAuth1(ctx).AccessToken(requestToken, "", verifier)
if err != nil {
return nil, err
}

return oauth1.NewToken(accessToken, accessSecret), nil
}

func (p *ProviderTwitter) AuthURL(ctx context.Context, state string) (string, error) {
c := p.OAuth1(ctx)

// We need to cheat so that callback validates on return
c.CallbackURL = c.CallbackURL + fmt.Sprintf("?state=%s&code=unused", state)

requestToken, _, err := c.RequestToken()
if err != nil {
return "", err
}

authzURL, err := c.AuthorizationURL(requestToken)
if err != nil {
return "", err
}

return authzURL.String(), nil
}

func (p *ProviderTwitter) CheckError(ctx context.Context, r *http.Request) error {
if r.URL.Query().Get("denied") == "" {
return nil
}

return errors.WithStack(herodot.ErrBadRequest.WithReasonf(`Unable to sign in with Twitter because the user denied the request.`))
}

func (p *ProviderTwitter) OAuth1(ctx context.Context) *oauth1.Config {
return &oauth1.Config{
ConsumerKey: p.config.ClientID,
ConsumerSecret: p.config.ClientSecret,
Endpoint: twitter.AuthorizeEndpoint,
CallbackURL: p.config.Redir(p.reg.Config().OIDCRedirectURIBase(ctx)),
}
}

func (p *ProviderTwitter) userInfoEndpoint() string {
for _, scope := range p.config.Scope {
if scope == "email" {
return twitterUserInfoWithEmail
}
}

return twitterUserInfoBase
}

func (p *ProviderTwitter) Claims(ctx context.Context, token *oauth1.Token) (*Claims, error) {
ctx = context.WithValue(ctx, oauth1.HTTPClient, p.reg.HTTPClient(ctx).HTTPClient)

c := p.OAuth1(ctx)
client := c.Client(ctx, token)
endpoint := p.userInfoEndpoint()

resp, err := client.Get(endpoint)
if err != nil {
return nil, errors.WithStack(herodot.ErrInternalServerError.WithReasonf("%s", err))
}
defer resp.Body.Close()

if err := logUpstreamError(p.reg.Logger(), resp); err != nil {
return nil, err
}

user := &twitterUser{}
if err := json.NewDecoder(resp.Body).Decode(user); err != nil {
return nil, errors.WithStack(herodot.ErrInternalServerError.WithReasonf("%s", err))
}

website := ""
if user.URL != nil {
website = *user.URL
}

return &Claims{
Issuer: endpoint,
Subject: user.IDStr,
Name: user.Name,
Picture: user.ProfileImageURLHTTPS,
Email: user.Email,
PreferredUsername: user.ScreenName,
Website: website,
}, nil
}

type twitterUser struct {
ID int `json:"id"`
IDStr string `json:"id_str"`
Name string `json:"name"`
ScreenName string `json:"screen_name"`
Location string `json:"location"`
Description string `json:"description"`
URL *string `json:"url,omitempty"`
Protected bool `json:"protected"`
FollowersCount int `json:"followers_count"`
FriendsCount int `json:"friends_count"`
ListedCount int `json:"listed_count"`
CreatedAt string `json:"created_at"`
FavouritesCount int `json:"favourites_count"`
Verified bool `json:"verified"`
StatusesCount int `json:"statuses_count"`
DefaultProfile bool `json:"default_profile"`
DefaultProfileImage bool `json:"default_profile_image"`
ProfileImageURLHTTPS string `json:"profile_image_url_https"`
WithheldInCountries []string `json:"withheld_in_countries"`
Suspended bool `json:"suspended"`
NeedsPhoneVerification bool `json:"needs_phone_verification"`
Email string `json:"email"`
}
Loading

0 comments on commit dbca630

Please sign in to comment.