Created
March 12, 2024 11:01
-
-
Save TheDhejavu/e4f4cb335fef1b0ac1da7589b797d1f3 to your computer and use it in GitHub Desktop.
Warpcast oauth implementation
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// ======= 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