diff --git a/backend/internxt/auth.go b/backend/internxt/auth.go new file mode 100644 index 0000000000000..0ec4fcdc37765 --- /dev/null +++ b/backend/internxt/auth.go @@ -0,0 +1,310 @@ +// Authentication handling for Internxt +package internxt + +import ( + "context" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "errors" + "fmt" + "net" + "net/http" + "time" + + "github.com/golang-jwt/jwt/v5" + internxtauth "github.com/internxt/rclone-adapter/auth" + internxtconfig "github.com/internxt/rclone-adapter/config" + "github.com/rclone/rclone/fs" + "github.com/rclone/rclone/fs/config/configmap" + "github.com/rclone/rclone/lib/oauthutil" + "github.com/tyler-smith/go-bip39" + "golang.org/x/oauth2" +) + +const ( + driveWebURL = "https://drive.internxt.com" + defaultLocalPort = "53682" + bindAddress = "127.0.0.1:" + defaultLocalPort + tokenExpiry2d = 48 * time.Hour +) + +// authResult holds the result from the SSO callback +type authResult struct { + mnemonic string + token string + err error +} + +// authServer handles the local HTTP callback for SSO login +type authServer struct { + listener net.Listener + server *http.Server + result chan authResult +} + +// newAuthServer creates a new local auth callback server +func newAuthServer() (*authServer, error) { + listener, err := net.Listen("tcp", bindAddress) + if err != nil { + return nil, fmt.Errorf("failed to start auth server on %s: %w", bindAddress, err) + } + + s := &authServer{ + listener: listener, + result: make(chan authResult, 1), + } + + mux := http.NewServeMux() + mux.HandleFunc("/", s.handleCallback) + s.server = &http.Server{Handler: mux} + + return s, nil +} + +// start begins serving requests in a goroutine +func (s *authServer) start() { + go func() { + err := s.server.Serve(s.listener) + if err != nil && err != http.ErrServerClosed { + s.result <- authResult{err: err} + } + }() +} + +// stop gracefully shuts down the server +func (s *authServer) stop() { + if s.server != nil { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + _ = s.server.Shutdown(ctx) + } +} + +// handleCallback processes the SSO callback with mnemonic and token +func (s *authServer) handleCallback(w http.ResponseWriter, r *http.Request) { + query := r.URL.Query() + mnemonicB64 := query.Get("mnemonic") + tokenB64 := query.Get("newToken") + + // Helper to redirect and report error + redirectWithError := func(err error) { + http.Redirect(w, r, driveWebURL+"/auth-link-error", http.StatusFound) + s.result <- authResult{err: err} + } + + if mnemonicB64 == "" || tokenB64 == "" { + redirectWithError(errors.New("missing mnemonic or token in callback")) + return + } + + mnemonicBytes, err := base64.StdEncoding.DecodeString(mnemonicB64) + if err != nil { + redirectWithError(fmt.Errorf("failed to decode mnemonic: %w", err)) + return + } + + // Validate that the mnemonic is a valid BIP39 mnemonic + mnemonic := string(mnemonicBytes) + if !bip39.IsMnemonicValid(mnemonic) { + redirectWithError(errors.New("mnemonic is not a valid BIP39 mnemonic")) + return + } + + tokenBytes, err := base64.StdEncoding.DecodeString(tokenB64) + if err != nil { + redirectWithError(fmt.Errorf("failed to decode token: %w", err)) + return + } + + // Redirect to success page + http.Redirect(w, r, driveWebURL+"/auth-link-ok", http.StatusFound) + + s.result <- authResult{ + mnemonic: mnemonic, + token: string(tokenBytes), + } +} + +// doAuth performs the interactive SSO authentication +func doAuth(ctx context.Context) (token, mnemonic string, err error) { + server, err := newAuthServer() + if err != nil { + return "", "", err + } + defer server.stop() + + server.start() + + callbackURL := "http://" + bindAddress + "/" + callbackB64 := base64.StdEncoding.EncodeToString([]byte(callbackURL)) + authURL := fmt.Sprintf("%s/login?universalLink=true&redirectUri=%s", driveWebURL, callbackB64) + + fs.Logf(nil, "") + fs.Logf(nil, "If your browser doesn't open automatically, visit this URL:") + fs.Logf(nil, "%s", authURL) + fs.Logf(nil, "") + fs.Logf(nil, "Log in and authorize rclone for access") + fs.Logf(nil, "Waiting for authentication...") + + if err = oauthutil.OpenURL(authURL); err != nil { + fs.Errorf(nil, "Failed to open browser: %v", err) + fs.Logf(nil, "Please manually open the URL above in your browser") + } + + select { + case result := <-server.result: + if result.err != nil { + return "", "", result.err + } + + fs.Logf(nil, "SSO login successful, refreshing token to fetch user data...") + + cfg := internxtconfig.NewDefaultToken(result.token) + resp, err := internxtauth.RefreshToken(ctx, cfg) + if err != nil { + return "", "", fmt.Errorf("failed to refresh token: %w", err) + } + + if resp.NewToken == "" { + return "", "", errors.New("refresh response missing newToken") + } + + fs.Logf(nil, "Authentication successful!") + return resp.NewToken, result.mnemonic, nil + + case <-time.After(5 * time.Minute): + return "", "", errors.New("authentication timeout after 5 minutes") + } +} + +type userInfo struct { + RootFolderID string + Bucket string + BridgeUser string + UserID string +} + +type userInfoConfig struct { + Token string +} + +// getUserInfo fetches user metadata from the refresh endpoint +func getUserInfo(ctx context.Context, cfg *userInfoConfig) (*userInfo, error) { + // Call the refresh endpoint to get all user metadata + refreshCfg := internxtconfig.NewDefaultToken(cfg.Token) + resp, err := internxtauth.RefreshToken(ctx, refreshCfg) + if err != nil { + return nil, fmt.Errorf("failed to fetch user info: %w", err) + } + + if resp.User.Bucket == "" { + return nil, errors.New("API response missing user.bucket") + } + if resp.User.RootFolderID == "" { + return nil, errors.New("API response missing user.rootFolderId") + } + if resp.User.BridgeUser == "" { + return nil, errors.New("API response missing user.bridgeUser") + } + if resp.User.UserID == "" { + return nil, errors.New("API response missing user.userId") + } + + info := &userInfo{ + RootFolderID: resp.User.RootFolderID, + Bucket: resp.User.Bucket, + BridgeUser: resp.User.BridgeUser, + UserID: resp.User.UserID, + } + + fs.Debugf(nil, "User info: rootFolderId=%s, bucket=%s", + info.RootFolderID, info.Bucket) + + return info, nil +} + +// parseJWTExpiry extracts the expiry time from a JWT token string +func parseJWTExpiry(tokenString string) (time.Time, error) { + parser := jwt.NewParser(jwt.WithoutClaimsValidation()) + token, _, err := parser.ParseUnverified(tokenString, jwt.MapClaims{}) + if err != nil { + return time.Time{}, fmt.Errorf("failed to parse token: %w", err) + } + + claims, ok := token.Claims.(jwt.MapClaims) + if !ok { + return time.Time{}, errors.New("invalid token claims") + } + + exp, ok := claims["exp"].(float64) + if !ok { + return time.Time{}, errors.New("token missing expiration") + } + + return time.Unix(int64(exp), 0), nil +} + +// jwtToOAuth2Token converts a JWT string to an oauth2.Token with expiry +func jwtToOAuth2Token(jwtString string) (*oauth2.Token, error) { + expiry, err := parseJWTExpiry(jwtString) + if err != nil { + return nil, err + } + + return &oauth2.Token{ + AccessToken: jwtString, + TokenType: "Bearer", + Expiry: expiry, + }, nil +} + +// computeBasicAuthHeader creates the BasicAuthHeader for bucket operations +// Following the pattern from SDK's auth/access.go:96-102 +func computeBasicAuthHeader(bridgeUser, userID string) string { + sum := sha256.Sum256([]byte(userID)) + hexPass := hex.EncodeToString(sum[:]) + creds := fmt.Sprintf("%s:%s", bridgeUser, hexPass) + return "Basic " + base64.StdEncoding.EncodeToString([]byte(creds)) +} + +// refreshJWTToken refreshes the token using Internxt's refresh endpoint +func refreshJWTToken(ctx context.Context, name string, m configmap.Mapper) error { + currentToken, err := oauthutil.GetToken(name, m) + if err != nil { + return fmt.Errorf("failed to get current token: %w", err) + } + + mnemonic, ok := m.Get("mnemonic") + if !ok || mnemonic == "" { + return errors.New("mnemonic is missing from configuration") + } + + cfg := internxtconfig.NewDefaultToken(currentToken.AccessToken) + resp, err := internxtauth.RefreshToken(ctx, cfg) + if err != nil { + return fmt.Errorf("refresh request failed: %w", err) + } + + if resp.NewToken == "" { + return errors.New("refresh response missing newToken") + } + + // Convert JWT to oauth2.Token format + token, err := jwtToOAuth2Token(resp.NewToken) + if err != nil { + return fmt.Errorf("failed to parse refreshed token: %w", err) + } + + err = oauthutil.PutToken(name, m, token, false) + if err != nil { + return fmt.Errorf("failed to save token: %w", err) + } + + if resp.User.Bucket != "" { + m.Set("bucket", resp.User.Bucket) + } + + fs.Debugf(name, "Token refreshed successfully, new expiry: %v", token.Expiry) + return nil +} diff --git a/backend/internxt/internxt.go b/backend/internxt/internxt.go index 6483cdf868c9a..07734e937bd98 100644 --- a/backend/internxt/internxt.go +++ b/backend/internxt/internxt.go @@ -24,6 +24,7 @@ import ( "github.com/rclone/rclone/fs/hash" "github.com/rclone/rclone/lib/dircache" "github.com/rclone/rclone/lib/encoder" + "github.com/rclone/rclone/lib/oauthutil" ) // Register with Fs @@ -32,7 +33,18 @@ func init() { Name: "internxt", Description: "Internxt Drive", NewFs: NewFs, + Config: Config, Options: []fs.Option{ + { + Name: "token", + Help: "Internxt auth token (JWT).\n\nLeave blank to trigger interactive login.", + IsPassword: true, + }, + { + Name: "mnemonic", + Help: "Internxt encryption mnemonic.\n\nLeave blank to trigger interactive login.", + IsPassword: true, + }, { Name: "simulateEmptyFiles", Default: false, @@ -52,6 +64,76 @@ func init() { ) } +// Config implements the interactive configuration flow +func Config(ctx context.Context, name string, m configmap.Mapper, configIn fs.ConfigIn) (*fs.ConfigOut, error) { + _, tokenOK := m.Get("token") + mnemonic, mnemonicOK := m.Get("mnemonic") + + switch configIn.State { + case "": + // Check if we already have valid credentials + if tokenOK && mnemonicOK && mnemonic != "" { + // Get oauth2.Token from config + oauthToken, err := oauthutil.GetToken(name, m) + if err != nil { + fs.Errorf(nil, "Failed to get token: %v", err) + return fs.ConfigGoto("reauth") + } + + if time.Until(oauthToken.Expiry) < tokenExpiry2d { + fs.Logf(nil, "Token expires soon, attempting refresh...") + err := refreshJWTToken(ctx, name, m) + if err != nil { + fs.Errorf(nil, "Failed to refresh token: %v", err) + return fs.ConfigConfirm("reauth", true, "config_reauth", + "Token refresh failed. Re-authenticate?") + } + fs.Logf(nil, "Token refreshed successfully") + return nil, nil + } + + return fs.ConfigConfirm("reauth", false, "config_reauth", + "Already authenticated. Re-authenticate?") + } + + return fs.ConfigGoto("auth") + + case "reauth": + if configIn.Result == "false" { + return nil, nil + } + return fs.ConfigGoto("auth") + + case "auth": + newToken, newMnemonic, err := doAuth(ctx) + if err != nil { + return nil, fmt.Errorf("authentication failed: %w", err) + } + + // Store mnemonic + m.Set("mnemonic", newMnemonic) + + // Store token in oauth2 format + oauthToken, err := jwtToOAuth2Token(newToken) + if err != nil { + return nil, fmt.Errorf("failed to create oauth2 token: %w", err) + } + + err = oauthutil.PutToken(name, m, oauthToken, true) + if err != nil { + return nil, fmt.Errorf("failed to save token: %w", err) + } + + fs.Logf(nil, "") + fs.Logf(nil, "Success! Authentication complete.") + fs.Logf(nil, "") + + return nil, nil + } + + return nil, fmt.Errorf("unknown state %q", configIn.State) +} + const ( EMPTY_FILE_EXT = ".__RCLONE_EMPTY__" ) @@ -62,18 +144,23 @@ var ( // Options holds configuration options for this interface type Options struct { + Token string `config:"token"` + Mnemonic string `config:"mnemonic"` Encoding encoder.MultiEncoder `config:"encoding"` SimulateEmptyFiles bool `config:"simulateEmptyFiles"` } // Fs represents an Internxt remote type Fs struct { - name string - root string - opt Options - dirCache *dircache.DirCache - cfg *config.Config - features *fs.Features + name string + root string + opt Options + dirCache *dircache.DirCache + cfg *config.Config + features *fs.Features + tokenRenewer *oauthutil.Renew + bridgeUser string + userID string } // Object holds the data for a remote file object @@ -117,23 +204,71 @@ func NewFs(ctx context.Context, name, root string, m configmap.Mapper) (fs.Fs, e return nil, err } - // TODO: Implement proper token-based authentication - cfg := &config.Config{} + if opt.Mnemonic == "" { + return nil, errors.New("mnemonic is required - please run: rclone config reconnect " + name + ":") + } + + oauthToken, err := oauthutil.GetToken(name, m) + if err != nil { + return nil, fmt.Errorf("failed to get token - please run: rclone config reconnect %s: - %w", name, err) + } + + oauthConfig := &oauthutil.Config{ + TokenURL: "https://gateway.internxt.com/drive/users/refresh", + } + + _, ts, err := oauthutil.NewClient(ctx, name, m, oauthConfig) + if err != nil { + return nil, fmt.Errorf("failed to create oauth client: %w", err) + } + + cfg := config.NewDefaultToken(oauthToken.AccessToken) + cfg.Mnemonic = opt.Mnemonic + + userInfo, err := getUserInfo(ctx, &userInfoConfig{Token: cfg.Token}) + if err != nil { + return nil, fmt.Errorf("failed to fetch user info: %w", err) + } + + cfg.RootFolderID = userInfo.RootFolderID + cfg.Bucket = userInfo.Bucket + cfg.BasicAuthHeader = computeBasicAuthHeader(userInfo.BridgeUser, userInfo.UserID) f := &Fs{ - name: name, - root: strings.Trim(root, "/"), - opt: *opt, - cfg: cfg, + name: name, + root: strings.Trim(root, "/"), + opt: *opt, + cfg: cfg, + bridgeUser: userInfo.BridgeUser, + userID: userInfo.UserID, } f.features = (&fs.Features{ CanHaveEmptyDirectories: true, }).Fill(ctx, f) + if ts != nil { + f.tokenRenewer = oauthutil.NewRenew(f.String(), ts, func() error { + err := refreshJWTToken(ctx, name, m) + if err != nil { + return err + } + + newToken, err := oauthutil.GetToken(name, m) + if err != nil { + return fmt.Errorf("failed to get refreshed token: %w", err) + } + f.cfg.Token = newToken.AccessToken + f.cfg.BasicAuthHeader = computeBasicAuthHeader(f.bridgeUser, f.userID) + + return nil + }) + f.tokenRenewer.Start() + } + f.dirCache = dircache.New(f.root, cfg.RootFolderID, f) - err := f.dirCache.FindRoot(ctx, false) + err = f.dirCache.FindRoot(ctx, false) if err != nil { // Assume it might be a file newRoot, remote := dircache.SplitPath(f.root) @@ -436,6 +571,13 @@ func (f *Fs) About(ctx context.Context) (*fs.Usage, error) { return usage, nil } +func (f *Fs) Shutdown(ctx context.Context) error { + if f.tokenRenewer != nil { + f.tokenRenewer.Shutdown() + } + return nil +} + // Open opens a file for streaming func (o *Object) Open(ctx context.Context, options ...fs.OpenOption) (io.ReadCloser, error) { fs.FixRangeOption(options, o.size) diff --git a/go.mod b/go.mod index 5a5fb9d15cf2c..4ed39d402f3df 100644 --- a/go.mod +++ b/go.mod @@ -40,6 +40,7 @@ require ( github.com/go-chi/chi/v5 v5.2.3 github.com/go-darwin/apfs v0.0.0-20211011131704-f84b94dbf348 github.com/go-git/go-billy/v5 v5.6.2 + github.com/golang-jwt/jwt/v5 v5.3.0 github.com/google/uuid v1.6.0 github.com/hanwen/go-fuse/v2 v2.9.0 github.com/henrybear327/Proton-API-Bridge v1.0.0 @@ -177,7 +178,6 @@ require ( github.com/go-viper/mapstructure/v2 v2.4.0 // indirect github.com/gofrs/flock v0.13.0 // indirect github.com/gogo/protobuf v1.3.2 // indirect - github.com/golang-jwt/jwt/v5 v5.3.0 // indirect github.com/google/btree v1.1.3 // indirect github.com/google/s2a-go v0.1.9 // indirect github.com/googleapis/enterprise-certificate-proxy v0.3.7 // indirect diff --git a/go.sum b/go.sum index 83a0cf8573c6d..e827c8879d353 100644 --- a/go.sum +++ b/go.sum @@ -72,8 +72,6 @@ github.com/Max-Sum/base32768 v0.0.0-20230304063302-18e6ce5945fd/go.mod h1:C8yoIf github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY= github.com/Microsoft/go-winio v0.6.1 h1:9/kr64B9VUZrLm5YYwbGtUJnMgqWVOdUAXu6Migciow= github.com/Microsoft/go-winio v0.6.1/go.mod h1:LRdKpFKfdobln8UmuiYcKPot9D2v6svN5+sAH+4kjUM= -github.com/Microsoft/go-winio v0.6.1 h1:9/kr64B9VUZrLm5YYwbGtUJnMgqWVOdUAXu6Migciow= -github.com/Microsoft/go-winio v0.6.1/go.mod h1:LRdKpFKfdobln8UmuiYcKPot9D2v6svN5+sAH+4kjUM= github.com/ProtonMail/bcrypt v0.0.0-20210511135022-227b4adcab57/go.mod h1:HecWFHognK8GfRDGnFQbW/LiV7A3MX3gZVs45vk5h8I= github.com/ProtonMail/bcrypt v0.0.0-20211005172633-e235017c1baf h1:yc9daCCYUefEs69zUkSzubzjBbL+cmOXgnmt9Fyd9ug= github.com/ProtonMail/bcrypt v0.0.0-20211005172633-e235017c1baf/go.mod h1:o0ESU9p83twszAU8LBeJKFAAMX14tISa0yk4Oo5TOqo= @@ -312,7 +310,6 @@ github.com/go-playground/validator/v10 v10.28.0 h1:Q7ibns33JjyW48gHkuFT91qX48KG0 github.com/go-playground/validator/v10 v10.28.0/go.mod h1:GoI6I1SjPBh9p7ykNE/yj3fFYbyDOpwMn5KXd+m2hUU= github.com/go-resty/resty/v2 v2.16.5 h1:hBKqmWrr7uRc3euHVqmh1HTHcKn99Smr7o5spptdhTM= github.com/go-resty/resty/v2 v2.16.5/go.mod h1:hkJtXbA2iKHzJheXYvQ8snQES5ZLGKMwQ07xAwp/fiA= -github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572 h1:tfuBGBXKqDEevZMzYi5KSi8KkcZtzBcTgAUUtapy0OI= github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI= github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8= github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= @@ -425,8 +422,6 @@ github.com/henrybear327/go-proton-api v1.0.0/go.mod h1:w63MZuzufKcIZ93pwRgiOtxMX github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= -github.com/internxt/rclone-adapter v0.0.0-20251128125449-40f1a8e381e7 h1:PXzi74RNqFc6yO6whAZDUkdFJCvrJCiZEC7Iq6dPXhY= -github.com/internxt/rclone-adapter v0.0.0-20251128125449-40f1a8e381e7/go.mod h1:XZ47hBE41lE1ixntK+ZuSLnS9ZGEtc6Pba0bKIo/CbU= github.com/jcmturner/aescts/v2 v2.0.0 h1:9YKLH6ey7H4eDBXW8khjYslgyqG2xZikXP0EQFKrle8= github.com/jcmturner/aescts/v2 v2.0.0/go.mod h1:AiaICIRyfYg35RUkr8yESTqvSy7csK90qZ5xfvvsoNs= github.com/jcmturner/dnsutils/v2 v2.0.0 h1:lltnkeZGL0wILNvrNiVCR6Ro5PGU/SeBvVO/8c/iPbo= @@ -672,8 +667,6 @@ github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= github.com/tyler-smith/go-bip39 v1.1.0 h1:5eUemwrMargf3BSLRRCalXT93Ns6pQJIjYQN2nyfOP8= github.com/tyler-smith/go-bip39 v1.1.0/go.mod h1:gUYDtqQw1JS3ZJ8UWVcGTGqqr6YIN3CWg+kkNaLt55U= -github.com/tyler-smith/go-bip39 v1.1.0 h1:5eUemwrMargf3BSLRRCalXT93Ns6pQJIjYQN2nyfOP8= -github.com/tyler-smith/go-bip39 v1.1.0/go.mod h1:gUYDtqQw1JS3ZJ8UWVcGTGqqr6YIN3CWg+kkNaLt55U= github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE= github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg= github.com/ulikunitz/xz v0.5.8/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14=