Skip to content

Commit 5d614b8

Browse files
committed
internxt: Implement authentication handling
1 parent 9d16076 commit 5d614b8

2 files changed

Lines changed: 386 additions & 3 deletions

File tree

backend/internxt/auth.go

Lines changed: 297 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,297 @@
1+
// Authentication handling for Internxt
2+
package internxt
3+
4+
import (
5+
"context"
6+
"encoding/base64"
7+
"encoding/json"
8+
"errors"
9+
"fmt"
10+
"net"
11+
"net/http"
12+
"time"
13+
14+
"github.com/golang-jwt/jwt/v5"
15+
"github.com/rclone/rclone/fs"
16+
"github.com/rclone/rclone/lib/oauthutil"
17+
)
18+
19+
const (
20+
driveWebURL = "https://drive.internxt.com"
21+
refreshURL = "https://gateway.internxt.com/drive/users/refresh"
22+
defaultLocalPort = "53682"
23+
bindAddress = "127.0.0.1:" + defaultLocalPort
24+
tokenExpiry2d = 48 * time.Hour
25+
)
26+
27+
// authResult holds the result from the SSO callback
28+
type authResult struct {
29+
mnemonic string
30+
token string
31+
bucket string
32+
err error
33+
}
34+
35+
// authServer handles the local HTTP callback for SSO login
36+
type authServer struct {
37+
listener net.Listener
38+
server *http.Server
39+
result chan authResult
40+
}
41+
42+
// newAuthServer creates a new local auth callback server
43+
func newAuthServer() (*authServer, error) {
44+
listener, err := net.Listen("tcp", bindAddress)
45+
if err != nil {
46+
return nil, fmt.Errorf("failed to start auth server on %s: %w", bindAddress, err)
47+
}
48+
49+
s := &authServer{
50+
listener: listener,
51+
result: make(chan authResult, 1),
52+
}
53+
54+
mux := http.NewServeMux()
55+
mux.HandleFunc("/", s.handleCallback)
56+
s.server = &http.Server{Handler: mux}
57+
58+
return s, nil
59+
}
60+
61+
// start begins serving requests in a goroutine
62+
func (s *authServer) start() {
63+
go func() {
64+
err := s.server.Serve(s.listener)
65+
if err != nil && err != http.ErrServerClosed {
66+
s.result <- authResult{err: err}
67+
}
68+
}()
69+
}
70+
71+
// stop gracefully shuts down the server
72+
func (s *authServer) stop() {
73+
if s.server != nil {
74+
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
75+
defer cancel()
76+
_ = s.server.Shutdown(ctx)
77+
}
78+
}
79+
80+
// handleCallback processes the SSO callback with mnemonic and token
81+
func (s *authServer) handleCallback(w http.ResponseWriter, r *http.Request) {
82+
query := r.URL.Query()
83+
mnemonicB64 := query.Get("mnemonic")
84+
tokenB64 := query.Get("newToken")
85+
86+
if mnemonicB64 == "" || tokenB64 == "" {
87+
http.Error(w, "Missing mnemonic or token", http.StatusBadRequest)
88+
s.result <- authResult{err: errors.New("missing mnemonic or token in callback")}
89+
return
90+
}
91+
92+
mnemonicBytes, err := base64.StdEncoding.DecodeString(mnemonicB64)
93+
if err != nil {
94+
http.Error(w, "Invalid mnemonic encoding", http.StatusBadRequest)
95+
s.result <- authResult{err: fmt.Errorf("failed to decode mnemonic: %w", err)}
96+
return
97+
}
98+
99+
tokenBytes, err := base64.StdEncoding.DecodeString(tokenB64)
100+
if err != nil {
101+
http.Error(w, "Invalid token encoding", http.StatusBadRequest)
102+
s.result <- authResult{err: fmt.Errorf("failed to decode token: %w", err)}
103+
return
104+
}
105+
106+
// Send success HTML response
107+
w.Header().Set("Content-Type", "text/html; charset=utf-8")
108+
w.WriteHeader(http.StatusOK)
109+
fmt.Fprintf(w, `<!DOCTYPE html>
110+
<html>
111+
<head>
112+
<p>placeholder</p>
113+
</html>`)
114+
115+
s.result <- authResult{
116+
mnemonic: string(mnemonicBytes),
117+
token: string(tokenBytes),
118+
}
119+
}
120+
121+
// doAuth performs the interactive SSO authentication
122+
func doAuth(ctx context.Context) (token, mnemonic string, err error) {
123+
server, err := newAuthServer()
124+
if err != nil {
125+
return "", "", err
126+
}
127+
defer server.stop()
128+
129+
server.start()
130+
131+
callbackURL := "http://" + bindAddress + "/"
132+
callbackB64 := base64.StdEncoding.EncodeToString([]byte(callbackURL))
133+
authURL := fmt.Sprintf("%s/login?universalLink=true&redirectUri=%s", driveWebURL, callbackB64)
134+
135+
fs.Logf(nil, "")
136+
fs.Logf(nil, "If your browser doesn't open automatically, visit this URL:")
137+
fs.Logf(nil, "%s", authURL)
138+
fs.Logf(nil, "")
139+
fs.Logf(nil, "Log in and authorize rclone for access")
140+
fs.Logf(nil, "Waiting for authentication...")
141+
142+
if err = oauthutil.OpenURL(authURL); err != nil {
143+
fs.Errorf(nil, "Failed to open browser: %v", err)
144+
fs.Logf(nil, "Please manually open the URL above in your browser")
145+
}
146+
147+
select {
148+
case result := <-server.result:
149+
if result.err != nil {
150+
return "", "", result.err
151+
}
152+
153+
fs.Logf(nil, "SSO login successful, refreshing token to fetch user data...")
154+
155+
refreshedToken, _, err := refreshTokenAndGetBucket(ctx, result.token)
156+
if err != nil {
157+
return "", "", fmt.Errorf("failed to refresh token: %w", err)
158+
}
159+
160+
fs.Logf(nil, "Authentication successful!")
161+
return refreshedToken, result.mnemonic, nil
162+
163+
case <-time.After(5 * time.Minute):
164+
return "", "", errors.New("authentication timeout after 5 minutes")
165+
}
166+
}
167+
168+
func tokenNeedsRefresh(tokenString string) (bool, error) {
169+
if tokenString == "" {
170+
return true, nil
171+
}
172+
173+
parser := jwt.NewParser(jwt.WithoutClaimsValidation())
174+
token, _, err := parser.ParseUnverified(tokenString, jwt.MapClaims{})
175+
if err != nil {
176+
return true, fmt.Errorf("failed to parse token: %w", err)
177+
}
178+
179+
claims, ok := token.Claims.(jwt.MapClaims)
180+
if !ok {
181+
return true, errors.New("invalid token claims")
182+
}
183+
184+
exp, ok := claims["exp"].(float64)
185+
if !ok {
186+
return true, errors.New("token missing expiration")
187+
}
188+
189+
expiryTime := time.Unix(int64(exp), 0)
190+
timeUntilExpiry := time.Until(expiryTime)
191+
192+
// Refresh if expiring within 2 days
193+
return timeUntilExpiry < tokenExpiry2d, nil
194+
}
195+
196+
func refreshToken(ctx context.Context, oldToken string) (string, error) {
197+
newToken, _, err := refreshTokenAndGetBucket(ctx, oldToken)
198+
return newToken, err
199+
}
200+
201+
func refreshTokenAndGetBucket(ctx context.Context, oldToken string) (newToken, bucket string, err error) {
202+
if oldToken == "" {
203+
return "", "", errors.New("cannot refresh empty token")
204+
}
205+
206+
req, err := http.NewRequestWithContext(ctx, http.MethodGet, refreshURL, nil)
207+
if err != nil {
208+
return "", "", err
209+
}
210+
req.Header.Set("Authorization", "Bearer "+oldToken)
211+
212+
client := &http.Client{Timeout: 30 * time.Second}
213+
resp, err := client.Do(req)
214+
if err != nil {
215+
return "", "", fmt.Errorf("refresh request failed: %w", err)
216+
}
217+
defer resp.Body.Close()
218+
219+
if resp.StatusCode != http.StatusOK {
220+
return "", "", fmt.Errorf("refresh failed with status %d", resp.StatusCode)
221+
}
222+
223+
var result struct {
224+
NewToken string `json:"newToken"`
225+
User struct {
226+
Bucket string `json:"bucket"`
227+
} `json:"user"`
228+
}
229+
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
230+
return "", "", fmt.Errorf("failed to parse refresh response: %w", err)
231+
}
232+
233+
if result.NewToken == "" {
234+
return "", "", errors.New("refresh response missing newToken")
235+
}
236+
237+
if result.User.Bucket == "" {
238+
return "", "", errors.New("refresh response missing user.bucket")
239+
}
240+
241+
return result.NewToken, result.User.Bucket, nil
242+
}
243+
244+
type userInfo struct {
245+
RootFolderID string
246+
Bucket string
247+
}
248+
249+
type userInfoConfig struct {
250+
Token string
251+
}
252+
253+
// getUserInfo fetches user metadata from the JWT token and refresh endpoint
254+
func getUserInfo(ctx context.Context, cfg *userInfoConfig) (*userInfo, error) {
255+
parser := jwt.NewParser(jwt.WithoutClaimsValidation())
256+
token, _, err := parser.ParseUnverified(cfg.Token, jwt.MapClaims{})
257+
if err != nil {
258+
return nil, fmt.Errorf("failed to parse token: %w", err)
259+
}
260+
261+
claims, ok := token.Claims.(jwt.MapClaims)
262+
if !ok {
263+
return nil, errors.New("invalid token claims")
264+
}
265+
266+
info := &userInfo{}
267+
268+
if payload, ok := claims["payload"].(map[string]any); ok {
269+
if uuid, ok := payload["uuid"].(string); ok {
270+
info.RootFolderID = uuid
271+
} else if rootFolderID, ok := payload["rootFolderId"].(string); ok {
272+
info.RootFolderID = rootFolderID
273+
}
274+
}
275+
276+
if info.RootFolderID == "" {
277+
if uuid, ok := claims["uuid"].(string); ok {
278+
info.RootFolderID = uuid
279+
} else if sub, ok := claims["sub"].(string); ok {
280+
info.RootFolderID = sub
281+
}
282+
}
283+
284+
if info.RootFolderID == "" {
285+
fs.Debugf(nil, "JWT token claims: %+v", claims)
286+
return nil, errors.New("could not find rootFolderId/uuid in JWT token")
287+
}
288+
289+
_, bucket, err := refreshTokenAndGetBucket(ctx, cfg.Token)
290+
if err != nil {
291+
return nil, fmt.Errorf("failed to get bucket: %w", err)
292+
}
293+
info.Bucket = bucket
294+
295+
fs.Debugf(nil, "User info: rootFolderId=%s, bucket=%s", info.RootFolderID, info.Bucket)
296+
return info, nil
297+
}

0 commit comments

Comments
 (0)