Some checks failed
CI / Run tests and lints (push) Failing after 29s
CI / Build CLI docs (push) Has been skipped
CI / Run Playwright end-to-end tests (mysql) (push) Failing after 3s
CI / Run Playwright end-to-end tests (postgres) (push) Failing after 2s
CI / Run Playwright end-to-end tests (sqlite) (push) Failing after 3s
CodeQL / Analyze (go) (push) Failing after 4s
CodeQL / Analyze (javascript) (push) Failing after 3s
Docker Image Scanners / scanners (push) Failing after 4s
Synchronize Issue Labels / Synchronize Issue Labels (push) Has been skipped
CI / Run end-to-end tests (postgres) (push) Failing after 19s
CI / Run end-to-end tests (sqlite) (push) Failing after 2s
CI / Run Playwright end-to-end tests (cockroach) (push) Failing after 2s
CI / Draft newsletter (push) Has been skipped
CI / Generate release (push) Has been skipped
CI / Pending approval Slack notification (push) Has been skipped
CI / Send newsletter (push) Has been skipped
174 lines
5.0 KiB
Go
174 lines
5.0 KiB
Go
// Copyright © 2024 Ory Corp
|
|
// SPDX-License-Identifier: Apache-2.0
|
|
|
|
package oidc
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
|
|
"github.com/ory/x/otelx"
|
|
|
|
"github.com/dghubble/oauth1"
|
|
"github.com/dghubble/oauth1/twitter"
|
|
"github.com/pkg/errors"
|
|
|
|
"github.com/ory/herodot"
|
|
)
|
|
|
|
var _ OAuth1Provider = (*ProviderX)(nil)
|
|
|
|
const (
|
|
xUserInfoBase = "https://api.twitter.com/1.1/account/verify_credentials.json"
|
|
xUserInfoWithEmail = xUserInfoBase + "?include_email=true"
|
|
)
|
|
|
|
type ProviderX struct {
|
|
config *Configuration
|
|
reg Dependencies
|
|
}
|
|
|
|
func (p *ProviderX) Config() *Configuration {
|
|
return p.config
|
|
}
|
|
|
|
func NewProviderX(
|
|
config *Configuration,
|
|
reg Dependencies,
|
|
) Provider {
|
|
return &ProviderX{
|
|
config: config,
|
|
reg: reg,
|
|
}
|
|
}
|
|
|
|
func (p *ProviderX) 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 *ProviderX) AuthURL(ctx context.Context, state string) (_ string, err error) {
|
|
ctx, span := p.reg.Tracer(ctx).Tracer().Start(ctx, "selfservice.strategy.oidc.ProviderLinkedIn.fetch")
|
|
defer otelx.End(span, &err)
|
|
|
|
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 {
|
|
span.RecordError(err)
|
|
return "", errors.WithStack(herodot.ErrUpstreamError().WithWrap(err).WithReasonf(`Unable to sign in with X because the OAuth1 request token could not be initialized: %s`, err))
|
|
}
|
|
|
|
authzURL, err := c.AuthorizationURL(requestToken)
|
|
if err != nil {
|
|
span.RecordError(err)
|
|
return "", errors.WithStack(herodot.ErrMisconfiguration().WithWrap(err).WithReasonf(`Unable to sign in with X because the OAuth1 authorization URL could not be parsed: %s`, err))
|
|
}
|
|
|
|
return authzURL.String(), nil
|
|
}
|
|
|
|
func (p *ProviderX) 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 X because the user denied the request.`))
|
|
}
|
|
|
|
func (p *ProviderX) OAuth1(ctx context.Context) *oauth1.Config {
|
|
return &oauth1.Config{
|
|
ConsumerKey: p.config.ClientID,
|
|
ConsumerSecret: p.config.ClientSecret,
|
|
Endpoint: twitter.AuthenticateEndpoint,
|
|
CallbackURL: p.config.Redir(p.reg.Config().OIDCRedirectURIBase(ctx)),
|
|
}
|
|
}
|
|
|
|
func (p *ProviderX) userInfoEndpoint() string {
|
|
for _, scope := range p.config.Scope {
|
|
if scope == "email" {
|
|
return xUserInfoWithEmail
|
|
}
|
|
}
|
|
|
|
return xUserInfoBase
|
|
}
|
|
|
|
func (p *ProviderX) 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.ErrUpstreamError().WithWrap(err).WithReasonf("%s", err))
|
|
}
|
|
defer func() { _ = resp.Body.Close() }()
|
|
|
|
if err := logUpstreamError(p.reg.Logger(), resp); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
user := &xUser{}
|
|
if err := json.NewDecoder(resp.Body).Decode(user); err != nil {
|
|
return nil, errors.WithStack(herodot.ErrUpstreamError().WithWrap(err).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 xUser 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"`
|
|
}
|