Skip to content

Commit 0629733

Browse files
linth2005camdenwang
andauthored
feat: add lark app oauth support (#550)
Co-authored-by: camdenwang <[email protected]>
1 parent 8d5b578 commit 0629733

File tree

4 files changed

+675
-0
lines changed

4 files changed

+675
-0
lines changed

providers/lark/lark.go

Lines changed: 307 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,307 @@
1+
package lark
2+
3+
import (
4+
"bytes"
5+
"encoding/json"
6+
"fmt"
7+
"io"
8+
"net/http"
9+
"net/url"
10+
"strings"
11+
"sync"
12+
"time"
13+
14+
"github.com/markbates/goth"
15+
"golang.org/x/oauth2"
16+
)
17+
18+
const (
19+
appAccessTokenURL string = "https://open.feishu.cn/open-apis/auth/v3/app_access_token/internal/" // get app_access_token
20+
21+
authURL string = "https://open.feishu.cn/open-apis/authen/v1/authorize" // obtain authorization code
22+
tokenURL string = "https://open.feishu.cn/open-apis/authen/v1/oidc/access_token" // get user_access_token
23+
refreshTokenURL string = "https://open.feishu.cn/open-apis/authen/v1/oidc/refresh_access_token" // refresh user_access_token
24+
endpointProfile string = "https://open.feishu.cn/open-apis/authen/v1/user_info" // get user info
25+
)
26+
27+
// Lark is the implementation of `goth.Provider` for accessing Lark
28+
type Lark interface {
29+
GetAppAccessToken() error // get app access token
30+
}
31+
32+
// Provider is the implementation of `goth.Provider` for accessing Lark
33+
type Provider struct {
34+
ClientKey string
35+
Secret string
36+
CallbackURL string
37+
HTTPClient *http.Client
38+
config *oauth2.Config
39+
providerName string
40+
41+
appAccessToken *appAccessToken
42+
}
43+
44+
// New creates a new Lark provider and sets up important connection details.
45+
func New(clientKey, secret, callbackURL string, scopes ...string) *Provider {
46+
p := &Provider{
47+
ClientKey: clientKey,
48+
Secret: secret,
49+
CallbackURL: callbackURL,
50+
providerName: "lark",
51+
appAccessToken: &appAccessToken{},
52+
}
53+
p.config = newConfig(p, authURL, tokenURL, scopes)
54+
return p
55+
}
56+
57+
func newConfig(provider *Provider, authURL, tokenURL string, scopes []string) *oauth2.Config {
58+
c := &oauth2.Config{
59+
ClientID: provider.ClientKey,
60+
ClientSecret: provider.Secret,
61+
RedirectURL: provider.CallbackURL,
62+
Endpoint: oauth2.Endpoint{
63+
AuthURL: authURL,
64+
TokenURL: tokenURL,
65+
},
66+
Scopes: []string{},
67+
}
68+
69+
if len(scopes) > 0 {
70+
c.Scopes = append(c.Scopes, scopes...)
71+
}
72+
return c
73+
}
74+
75+
func (p *Provider) Client() *http.Client {
76+
return goth.HTTPClientWithFallBack(p.HTTPClient)
77+
}
78+
79+
func (p *Provider) Name() string {
80+
return p.providerName
81+
}
82+
83+
func (p *Provider) SetName(name string) {
84+
p.providerName = name
85+
}
86+
87+
type appAccessToken struct {
88+
Token string
89+
ExpiresAt time.Time
90+
rMutex sync.RWMutex
91+
}
92+
93+
type appAccessTokenReq struct {
94+
AppID string `json:"app_id"` // 自建应用的 app_id
95+
AppSecret string `json:"app_secret"` // 自建应用的 app_secret
96+
}
97+
98+
type appAccessTokenResp struct {
99+
Code int `json:"code"` // 错误码
100+
Msg string `json:"msg"` // 错误信息
101+
AppAccessToken string `json:"app_access_token"` // 用于调用应用级接口的 app_access_token
102+
Expire int64 `json:"expire"` // app_access_token 的过期时间
103+
}
104+
105+
// GetAppAccessToken get lark app access token
106+
func (p *Provider) GetAppAccessToken() error {
107+
// get from cache app access token
108+
p.appAccessToken.rMutex.RLock()
109+
if time.Now().Before(p.appAccessToken.ExpiresAt) {
110+
p.appAccessToken.rMutex.RUnlock()
111+
return nil
112+
}
113+
p.appAccessToken.rMutex.RUnlock()
114+
115+
reqBody, err := json.Marshal(&appAccessTokenReq{
116+
AppID: p.ClientKey,
117+
AppSecret: p.Secret,
118+
})
119+
if err != nil {
120+
return fmt.Errorf("failed to marshal request body: %w", err)
121+
}
122+
123+
req, err := http.NewRequest(http.MethodPost, appAccessTokenURL, bytes.NewBuffer(reqBody))
124+
if err != nil {
125+
return fmt.Errorf("failed to create app access token request: %w", err)
126+
}
127+
req.Header.Set("Content-Type", "application/json")
128+
129+
resp, err := p.Client().Do(req)
130+
if err != nil {
131+
return fmt.Errorf("failed to send app access token request: %w", err)
132+
}
133+
defer resp.Body.Close()
134+
135+
if resp.StatusCode != http.StatusOK {
136+
return fmt.Errorf("unexpected status code while fetching app access token: %d", resp.StatusCode)
137+
}
138+
139+
tokenResp := new(appAccessTokenResp)
140+
if err = json.NewDecoder(resp.Body).Decode(tokenResp); err != nil {
141+
return fmt.Errorf("failed to decode app access token response: %w", err)
142+
}
143+
144+
if tokenResp.Code != 0 {
145+
return fmt.Errorf("failed to get app access token: code:%v msg: %s", tokenResp.Code, tokenResp.Msg)
146+
}
147+
148+
// update local cache
149+
expirationDuration := time.Duration(tokenResp.Expire) * time.Second
150+
p.appAccessToken.rMutex.Lock()
151+
p.appAccessToken.Token = tokenResp.AppAccessToken
152+
p.appAccessToken.ExpiresAt = time.Now().Add(expirationDuration)
153+
p.appAccessToken.rMutex.Unlock()
154+
155+
return nil
156+
}
157+
158+
func (p *Provider) BeginAuth(state string) (goth.Session, error) {
159+
// build lark auth url
160+
u, err := url.Parse(p.config.AuthCodeURL(state))
161+
if err != nil {
162+
panic(err)
163+
}
164+
query := u.Query()
165+
query.Del("response_type")
166+
query.Del("client_id")
167+
query.Add("app_id", p.ClientKey)
168+
u.RawQuery = query.Encode()
169+
170+
return &Session{
171+
AuthURL: u.String(),
172+
}, nil
173+
}
174+
175+
func (p *Provider) UnmarshalSession(data string) (goth.Session, error) {
176+
s := &Session{}
177+
err := json.NewDecoder(strings.NewReader(data)).Decode(s)
178+
return s, err
179+
}
180+
181+
func (p *Provider) Debug(b bool) {
182+
}
183+
184+
type getUserAccessTokenResp struct {
185+
AccessToken string `json:"access_token"`
186+
RefreshToken string `json:"refresh_token"`
187+
TokenType string `json:"token_type"`
188+
ExpiresIn int `json:"expires_in"`
189+
RefreshExpiresIn int `json:"refresh_expires_in"`
190+
Scope string `json:"scope"`
191+
}
192+
193+
func (p *Provider) RefreshToken(refreshToken string) (*oauth2.Token, error) {
194+
if err := p.GetAppAccessToken(); err != nil {
195+
return nil, fmt.Errorf("failed to get app access token: %w", err)
196+
}
197+
reqBody := strings.NewReader(`{"grant_type":"refresh_token","refresh_token":"` + refreshToken + `"}`)
198+
199+
req, err := http.NewRequest(http.MethodPost, refreshTokenURL, reqBody)
200+
if err != nil {
201+
return nil, fmt.Errorf("failed to create refresh token request: %w", err)
202+
}
203+
req.Header.Set("Content-Type", "application/json")
204+
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", p.appAccessToken.Token))
205+
206+
resp, err := p.Client().Do(req)
207+
if err != nil {
208+
return nil, fmt.Errorf("failed to send refresh token request: %w", err)
209+
}
210+
defer resp.Body.Close()
211+
212+
if resp.StatusCode != http.StatusOK {
213+
return nil, fmt.Errorf("unexpected status code while refreshing token: %d", resp.StatusCode)
214+
}
215+
216+
var oauthResp commResponse[getUserAccessTokenResp]
217+
err = json.NewDecoder(resp.Body).Decode(&oauthResp)
218+
if err != nil {
219+
return nil, fmt.Errorf("failed to decode refreshed token: %w", err)
220+
}
221+
if oauthResp.Code != 0 {
222+
return nil, fmt.Errorf("failed to refresh token: code:%v msg: %s", oauthResp.Code, oauthResp.Msg)
223+
}
224+
225+
token := oauth2.Token{
226+
AccessToken: oauthResp.Data.AccessToken,
227+
RefreshToken: oauthResp.Data.RefreshToken,
228+
Expiry: time.Now().Add(time.Duration(oauthResp.Data.ExpiresIn) * time.Second),
229+
}
230+
231+
return &token, nil
232+
}
233+
234+
func (p *Provider) RefreshTokenAvailable() bool {
235+
return true
236+
}
237+
238+
type commResponse[T any] struct {
239+
Code int `json:"code"`
240+
Msg string `json:"msg"`
241+
Data T `json:"data"`
242+
}
243+
244+
type larkUser struct {
245+
OpenID string `json:"open_id"`
246+
UnionID string `json:"union_id"`
247+
UserID string `json:"user_id"`
248+
Name string `json:"name"`
249+
Email string `json:"enterprise_email"`
250+
AvatarURL string `json:"avatar_url"`
251+
Mobile string `json:"mobile,omitempty"`
252+
}
253+
254+
// FetchUser will go to Lark and access basic information about the user.
255+
func (p *Provider) FetchUser(session goth.Session) (goth.User, error) {
256+
sess := session.(*Session)
257+
user := goth.User{
258+
AccessToken: sess.AccessToken,
259+
Provider: p.Name(),
260+
RefreshToken: sess.RefreshToken,
261+
ExpiresAt: sess.ExpiresAt,
262+
}
263+
if user.AccessToken == "" {
264+
return user, fmt.Errorf("%s cannot get user information without accessToken", p.providerName)
265+
}
266+
267+
req, err := http.NewRequest("GET", endpointProfile, nil)
268+
if err != nil {
269+
return user, fmt.Errorf("%s failed to create request: %w", p.providerName, err)
270+
}
271+
req.Header.Set("Authorization", "Bearer "+user.AccessToken)
272+
273+
resp, err := p.Client().Do(req)
274+
if err != nil {
275+
return user, fmt.Errorf("%s failed to get user information: %w", p.providerName, err)
276+
}
277+
defer resp.Body.Close()
278+
279+
if resp.StatusCode != http.StatusOK {
280+
return user, fmt.Errorf("%s responded with a %d trying to fetch user information", p.providerName, resp.StatusCode)
281+
}
282+
283+
responseBytes, err := io.ReadAll(resp.Body)
284+
if err != nil {
285+
return user, fmt.Errorf("failed to read response body: %w", err)
286+
}
287+
288+
var oauthResp commResponse[larkUser]
289+
if err = json.Unmarshal(responseBytes, &oauthResp); err != nil {
290+
return user, fmt.Errorf("failed to decode user info: %w", err)
291+
}
292+
if oauthResp.Code != 0 {
293+
return user, fmt.Errorf("failed to get user info: code:%v msg: %s", oauthResp.Code, oauthResp.Msg)
294+
}
295+
296+
u := oauthResp.Data
297+
user.UserID = u.UserID
298+
user.Name = u.Name
299+
user.Email = u.Email
300+
user.AvatarURL = u.AvatarURL
301+
user.NickName = u.Name
302+
303+
if err = json.Unmarshal(responseBytes, &user.RawData); err != nil {
304+
return user, err
305+
}
306+
return user, nil
307+
}

0 commit comments

Comments
 (0)