Skip to content

Instantly share code, notes, and snippets.

@TheDhejavu
Created March 12, 2024 11:01
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save TheDhejavu/e4f4cb335fef1b0ac1da7589b797d1f3 to your computer and use it in GitHub Desktop.
Save TheDhejavu/e4f4cb335fef1b0ac1da7589b797d1f3 to your computer and use it in GitHub Desktop.
Warpcast oauth implementation
// ======= NEYNAR CLIENT =======
type HTTPClient interface {
Do(req *http.Request) (*http.Response, error)
Get(url string) (*http.Response, error)
}
type NeynarClient struct {
Client HTTPClient
Cfg Config
}
func NewNeynarClient(config Config) *NeynarClient {
requestClient := HTTPClient(&http.Client{})
return &NeynarClient{
Cfg: config,
Client: requestClient,
}
}
type Signer struct {
SignerUUID string `json:"signer_uuid"`
PublicKey string `json:"public_key"`
Status WarpcastApprovalStatus `json:"status"`
FID int64 `json:"fid"`
ApprovalURL string `json:"signer_approval_url"`
}
func (nc *NeynarClient) PostSigner(ctx context.Context) (*Signer, error) {
var url = fmt.Sprintf("%s/v2/farcaster/signer", nc.Cfg.Warpcast.Neynar.URL)
req, err := http.NewRequest(http.MethodPost, url, nil)
if err != nil {
return nil, err
}
req.Header.Set("api_key", nc.Cfg.Warpcast.Neynar.APIKey)
req.Header.Set("Content-Type", "application/json")
resp, err := nc.Client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
var signerResponse Signer
if err := json.NewDecoder(resp.Body).Decode(&signerResponse); err != nil {
return nil, err
}
return &signerResponse, nil
}
func (nc *NeynarClient) GetSignerStatus(ctx context.Context, signerUUID string) (*Signer, error) {
url := fmt.Sprintf("%s/v2/farcaster/signer?signer_uuid=%s", nc.Cfg.Warpcast.Neynar.URL, signerUUID)
req, err := http.NewRequest(http.MethodGet, url, nil)
if err != nil {
return nil, err
}
req.Header.Set("api_key", nc.Cfg.Warpcast.Neynar.APIKey)
req.Header.Set("Content-Type", "application/json")
resp, err := nc.Client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
bodyBytes, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("unable to read body: %w", err)
}
bodyString := string(bodyBytes)
return nil, fmt.Errorf("unable fetch data: %s", bodyString)
}
var signerResponse Signer
if err := json.NewDecoder(resp.Body).Decode(&signerResponse); err != nil {
return nil, err
}
return &signerResponse, nil
}
type SignatureRequest struct {
PublicKey string `json:"publicKey"`
}
type SignatureResponse struct {
Signature string `json:"signature"`
Deadline int64 `json:"deadline"`
AppFID string `json:"appFid"`
}
func (nc *NeynarClient) GetSignature(ctx context.Context, publicKey string) (*SignatureResponse, error) {
url := fmt.Sprintf("%s/signature", nc.Cfg.Warpcast.Neynar.SigAPIURL)
requestBody := SignatureRequest{
PublicKey: publicKey,
}
bodyAsBytes, err := json.Marshal(requestBody)
if err != nil {
return nil, fmt.Errorf("unable to marshal body: %w", err)
}
req, err := http.NewRequest(http.MethodPost, url, bytes.NewBuffer(bodyAsBytes))
if err != nil {
return nil, err
}
req.Header.Set("X-API-KEY", nc.Cfg.Warpcast.Neynar.SigAPIKey)
req.Header.Set("Content-Type", "application/json")
resp, err := nc.Client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
bodyBytes, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("unable to read body: %w", err)
}
bodyString := string(bodyBytes)
return nil, fmt.Errorf("unable fetch data: %s", bodyString)
}
var response *SignatureResponse
if err := json.NewDecoder(resp.Body).Decode(&response); err != nil {
return nil, err
}
return response, nil
}
type RegisterSignedKeyRequest struct {
SignerUUID string `json:"signer_uuid"`
Signature string `json:"signature"`
AppFID string `json:"app_fid"`
Deadline int64 `json:"deadline"`
}
func (nc *NeynarClient) RegisterSignedKey(ctx context.Context, signerUUID, appFID, signature string, deadline int64) (*Signer, error) {
requestBody := RegisterSignedKeyRequest{
SignerUUID: signerUUID,
Signature: signature,
AppFID: appFID,
Deadline: deadline,
}
bodyAsBytes, err := json.Marshal(requestBody)
if err != nil {
return nil, fmt.Errorf("unable to marshal body: %w", err)
}
url := fmt.Sprintf("%s/v2/farcaster/signer/signed_key", nc.Cfg.Warpcast.Neynar.URL)
req, err := http.NewRequest(http.MethodPost, url, bytes.NewBuffer(bodyAsBytes))
if err != nil {
return nil, err
}
req.Header.Set("api_key", nc.Cfg.Warpcast.Neynar.APIKey)
req.Header.Set("Content-Type", "application/json")
resp, err := nc.Client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
bodyBytes, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("unable to read body: %w", err)
}
bodyString := string(bodyBytes)
return nil, fmt.Errorf("unable fetch data: %s", bodyString)
}
var signerResponse Signer
if err := json.NewDecoder(resp.Body).Decode(&signerResponse); err != nil {
return nil, err
}
return &signerResponse, nil
}
type UserPFP struct {
URL string `json:"url"`
}
type UserBio struct {
Text string `json:"text"`
MentionedProfiles []interface{} `json:"mentionedProfiles"`
}
type UserProfile struct {
Bio UserBio `json:"bio"`
}
type UserViewerContext struct {
Following bool `json:"following"`
FollowedBy bool `json:"followedBy"`
}
type UserInformation struct {
FID int `json:"fid"`
CustodyAddress string `json:"custodyAddress"`
Username string `json:"username"`
DisplayName string `json:"displayName"`
PFP UserPFP `json:"pfp"`
Profile UserProfile `json:"profile"`
FollowerCount int `json:"followerCount"`
FollowingCount int `json:"followingCount"`
Verifications []string `json:"verifications"`
ActiveStatus string `json:"activeStatus"`
ViewerContext UserViewerContext `json:"viewerContext"`
}
type UserInformationResult struct {
User UserInformation `json:"user"`
}
type GetUserInformationResponse struct {
Result UserInformationResult `json:"result"`
}
func (nc *NeynarClient) GetUserInformationByFID(ctx context.Context, fid int64) (*UserInformation, error) {
url := fmt.Sprintf("%s/v1/farcaster/user?fid=%d", nc.Cfg.Warpcast.Neynar.URL, fid)
req, err := http.NewRequest(http.MethodGet, url, nil)
if err != nil {
return nil, err
}
req.Header.Add("api_key", nc.Cfg.Warpcast.Neynar.APIKey)
resp, err := nc.Client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("error fetching user information: %s", resp.Status)
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
var response GetUserInformationResponse
err = json.Unmarshal(body, &response)
if err != nil {
return nil, err
}
return &response.Result.User, nil
}
func (user *UserInformation) ToRawData() map[string]interface{} {
return map[string]interface{}{
"fid": user.FID,
"custodyAddress": user.CustodyAddress,
"username": user.Username,
"displayName": user.DisplayName,
"pfp": map[string]interface{}{
"url": user.PFP.URL,
},
"profile": map[string]interface{}{
"bio": map[string]interface{}{
"text": user.Profile.Bio.Text,
"mentionedProfiles": user.Profile.Bio.MentionedProfiles,
},
},
"followerCount": user.FollowerCount,
"followingCount": user.FollowingCount,
"verifications": user.Verifications,
"activeStatus": user.ActiveStatus,
"viewerContext": map[string]interface{}{
"following": user.ViewerContext.Following,
"followedBy": user.ViewerContext.FollowedBy,
},
}
}
// ==== SESSION ===
type WarpcastApprovalStatus string
const (
Initialized WarpcastApprovalStatus = "initialized"
Generated WarpcastApprovalStatus = "generated"
PendingApproval WarpcastApprovalStatus = "pending_approval"
Approved WarpcastApprovalStatus = "approved"
Revoked WarpcastApprovalStatus = "revoked"
)
func (status WarpcastApprovalStatus) ToUpper() string {
return strings.ToUpper(string(status))
}
type Session struct {
AuthURL string
Signer Signer
ExpiresAt time.Time
}
func (s Session) GetAuthURL() (string, error) {
if s.AuthURL == "" {
return "", errors.New("an auth url has not been set")
}
return s.AuthURL, nil
}
func (s *Session) Authorize(provider goth.Provider, params goth.Params) (string, error) {
// Farcaster authorization is done and approved via warpcast mobile application.
// here, we just check the status of their authorization based on the session.
p := provider.(*Provider)
signerStatus, err := p.neynarClient.GetSignerStatus(context.Background(), s.Signer.SignerUUID)
if err != nil {
return "", err
}
if signerStatus.Status != Approved {
return "", fmt.Errorf("failed with authorization status of %s", signerStatus.Status)
}
s.Signer.FID = signerStatus.FID
s.Signer.Status = signerStatus.Status
return s.Signer.SignerUUID, nil
}
func (s *Session) SignerStatus(provider goth.Provider) (*Signer, error) {
p := provider.(*Provider)
signerStatus, err := p.neynarClient.GetSignerStatus(context.Background(), s.Signer.SignerUUID)
if err != nil {
return nil, err
}
return signerStatus, nil
}
func (session *Session) GenerateAuthSession(provider goth.Provider) (*Session, error) {
p := provider.(*Provider)
signer, deadline, err := p.generateFarcasterSigner(context.Background())
if err != nil {
return session, err
}
session.Signer = *signer
session.ExpiresAt = deadline
return session, nil
}
func (p *Provider) FetchUser(session goth.Session) (goth.User, error) {
sess := session.(*Session)
if sess.Signer.FID == 0 {
return goth.User{}, errors.New("FID is required")
}
authUser, err := p.neynarClient.GetUserInformationByFID(context.Background(), sess.Signer.FID)
if err != nil {
return goth.User{}, err
}
user := goth.User{
Provider: p.Name(),
UserID: fmt.Sprintf("%d", authUser.FID),
NickName: authUser.Username,
Description: authUser.Profile.Bio.Text,
AvatarURL: authUser.PFP.URL,
RawData: authUser.ToRawData(),
ExpiresAt: sess.ExpiresAt,
}
return user, nil
}
// Marshal the session into a string
func (s Session) Marshal() string {
b, _ := json.Marshal(s)
return string(b)
}
func (s Session) String() string {
return s.Marshal()
}
// UnmarshalSession will unmarshal a JSON string into a session.
func (p *Provider) UnmarshalSession(data string) (goth.Session, error) {
sess := &Session{}
err := json.NewDecoder(strings.NewReader(data)).Decode(sess)
return sess, err
}
// ===== WARPCAST ====
type Provider struct {
neynarClient *NeynarClient
AuthURL string
providerName string
}
func New(authURL string, neynarClient *NeynarClient) *Provider {
return &Provider{
neynarClient: neynarClient,
AuthURL: authURL,
providerName: "warpcast",
}
}
func (p *Provider) Name() string {
return p.providerName
}
func (p *Provider) BeginAuth(state string) (goth.Session, error) {
session := &Session{}
url := fmt.Sprintf("%s?state=%s", p.AuthURL, state)
session.AuthURL = url
session.Signer = Signer{
Status: Initialized,
}
return session, nil
}
func (p *Provider) generateFarcasterSigner(ctx context.Context) (*Signer, time.Time, error) {
signer, err := p.neynarClient.PostSigner(ctx)
if err != nil {
return nil, time.Time{}, fmt.Errorf("unable to create signer: %w", err)
}
signatureResult, err := p.neynarClient.GetSignature(ctx, signer.PublicKey)
if err != nil {
return nil, time.Time{}, fmt.Errorf("unable to create signature: %w", err)
}
signer, err = p.neynarClient.RegisterSignedKey(ctx, signer.SignerUUID, signatureResult.AppFID, signatureResult.Signature, signatureResult.Deadline)
if err != nil {
return nil, time.Time{}, fmt.Errorf("unable to register signed key: %w", err)
}
timestamp := time.Unix(signatureResult.Deadline, 0)
return signer, timestamp, nil
}
func (p *Provider) Debug(debug bool) {}
func (p *Provider) RefreshToken(refreshToken string) (*oauth2.Token, error) {
return nil, errors.New("refresh token not supported by farcaster provider")
}
func (p *Provider) RefreshTokenAvailable() bool {
return false
}
func (p *Provider) SetName(name string) {
p.providerName = name
}
type Config struct {
Warpcast struct {
Neynar struct {
SigAPIURL string `env-required:"true" env:"WARPCAST_NEYNAR_SIG_API_URL"`
SigAPIKey string `env-required:"true" env:"WARPCAST_NEYNAR_SIG_API_KEY"`
URL string `env-required:"true" env:"WARPCAST_NEYNAR_API_URL"`
APIKey string `env-required:"true" env:"WARPCAST_NEYNAR_API_KEY"`
}
AuthURL string `env-required:"true" env:"WARPCAST_AUTH_URL"`
CallbackURL string `env-required:"true" env:"WARPCAST_CALLBACK_URL"`
}
}
func ParseURL(rawURL string) (*url.URL, error) {
// Parse the raw URL
parsedURL, err := url.Parse(rawURL)
if err != nil {
return nil, err
}
fragment := parsedURL.Fragment
fragmentValues, err := url.ParseQuery(fragment)
if err != nil {
return nil, err
}
mergedValues := parsedURL.Query()
for key, values := range fragmentValues {
for _, value := range values {
mergedValues.Add(key, value)
}
}
parsedURL.RawQuery = mergedValues.Encode()
parsedURL.Fragment = ""
return parsedURL, nil
}
func main() {
var cfg Config
err := cleanenv.ReadEnv(&cfg)
if err != nil {
log.Fatal().Err(err).Msg("clean env failed to read env variables")
}
var providerInstance goth.Provider = New(cfg.Warpcast.AuthURL, NewNeynarClient(cfg))
// Generate Authorization URL....
state := uuid.NewString()
sess, err := providerInstance.BeginAuth(state)
if err != nil {
log.Fatal().Err(err)
}
authURL, err := sess.GetAuthURL()
if err != nil {
log.Fatal().Err(err)
}
// Authorize......
parsedURL, err := ParseURL(authURL)
if err != nil {
log.Fatal().Err(err)
}
var authSess goth.Session
authSess, err = providerInstance.UnmarshalSession(session.Session)
if err != nil {
log.Fatal().Err(err)
}
_, err = authSess.Authorize(providerInstance, parsedURL.Query())
if err != nil {
log.Error().Err(err).Msg("Failed to authorize session")
}
authUser, err := providerInstance.FetchUser(sess)
if err != nil {
log.Error().Err(err).Msg("FetchUser failed")
}
fmt.Println(authUser)
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment