diff --git a/backend/internxt/auth.go b/backend/internxt/auth.go index e555bd0fe9f7c..8599703057b6a 100644 --- a/backend/internxt/auth.go +++ b/backend/internxt/auth.go @@ -8,8 +8,6 @@ import ( "encoding/hex" "errors" "fmt" - "net" - "net/http" "time" "github.com/golang-jwt/jwt/v5" @@ -18,167 +16,9 @@ import ( "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 - } - - cfg := internxtconfig.NewDefaultToken(string(tokenBytes)) - resp, err := internxtauth.RefreshToken(r.Context(), cfg) - if err != nil { - redirectWithError(fmt.Errorf("failed to refresh token: %w", err)) - return - } - - if resp.NewToken == "" { - redirectWithError(errors.New("refresh response missing newToken")) - return - } - - http.Redirect(w, r, driveWebURL+"/auth-link-ok", http.StatusFound) - - s.result <- authResult{ - mnemonic: mnemonic, - token: resp.NewToken, - } -} - -// 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, "Authentication successful!") - return result.token, result.mnemonic, nil - - case <-ctx.Done(): - return "", "", fmt.Errorf("authentication cancelled: %w", ctx.Err()) - - case <-time.After(5 * time.Minute): - return "", "", errors.New("authentication timeout after 5 minutes") - } -} type userInfo struct { RootFolderID string diff --git a/backend/internxt/internxt.go b/backend/internxt/internxt.go index f948c0002a491..0845275030b35 100644 --- a/backend/internxt/internxt.go +++ b/backend/internxt/internxt.go @@ -13,6 +13,7 @@ import ( "strings" "time" + "github.com/internxt/rclone-adapter/auth" "github.com/internxt/rclone-adapter/buckets" config "github.com/internxt/rclone-adapter/config" sdkerrors "github.com/internxt/rclone-adapter/errors" @@ -58,99 +59,53 @@ func init() { Name: "internxt", Description: "Internxt Drive", NewFs: NewFs, - Config: Config, - Options: []fs.Option{ - { - Name: "skip_hash_validation", - Default: true, - Advanced: true, - Help: "Skip hash validation when downloading files.\n\nBy default, hash validation is disabled. Set this to false to enable validation.", - }, - { - Name: rclone_config.ConfigEncoding, - Help: rclone_config.ConfigEncodingHelp, - Advanced: true, - Default: encoder.EncodeInvalidUtf8 | - encoder.EncodeSlash | - encoder.EncodeBackSlash | - encoder.EncodeRightPeriod | - encoder.EncodeDot | - encoder.EncodeCrLf, - }, + Options: []fs.Option{{ + Name: "email", + Help: "Email of your Internxt account.", + Required: true, + Sensitive: true, + }, { + Name: "pass", + Help: "Password.", + Required: true, + IsPassword: true, + }, { + Name: "2fa", + Help: "Two-factor authentication code (if enabled on your account).", + }, { + Name: "mnemonic", + Help: "Mnemonic (internal use only)", + Required: false, + Advanced: true, + Sensitive: true, + Hide: fs.OptionHideBoth, + }, { + Name: "skip_hash_validation", + Default: true, + Advanced: true, + Help: "Skip hash validation when downloading files.\n\nBy default, hash validation is disabled. Set this to false to enable validation.", + }, { + Name: rclone_config.ConfigEncoding, + Help: rclone_config.ConfigEncodingHelp, + Advanced: true, + Default: encoder.EncodeInvalidUtf8 | + encoder.EncodeSlash | + encoder.EncodeBackSlash | + encoder.EncodeRightPeriod | + encoder.EncodeDot | + encoder.EncodeCrLf, }}, - ) -} - -// 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("auth") - } - - 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.ConfigGoto("auth") - } - fs.Logf(nil, "Token refreshed successfully") - return nil, nil - } - - // Token is valid - complete config without re-auth prompt - fs.Logf(nil, "Existing credentials are valid") - 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 (obscured) - m.Set("mnemonic", obscure.MustObscure(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) + }) } -// Options holds configuration options for this interface +// Options defines the configuration for this backend type Options struct { - Token string `config:"token"` + Email string `config:"email"` + Pass string `config:"pass"` + TwoFA string `config:"2fa"` Mnemonic string `config:"mnemonic"` - Encoding encoder.MultiEncoder `config:"encoding"` SkipHashValidation bool `config:"skip_hash_validation"` + Encoding encoder.MultiEncoder `config:"encoding"` } // Fs represents an Internxt remote @@ -208,20 +163,43 @@ func NewFs(ctx context.Context, name, root string, m configmap.Mapper) (fs.Fs, e return nil, err } - if opt.Mnemonic == "" { - return nil, errors.New("mnemonic is required - please run: rclone config reconnect " + name + ":") + if opt.Pass != "" { + var err error + opt.Pass, err = obscure.Reveal(opt.Pass) + if err != nil { + return nil, fmt.Errorf("couldn't decrypt password: %w", err) + } } - // Reveal the obscured mnemonic - var err error - opt.Mnemonic, err = obscure.Reveal(opt.Mnemonic) - if err != nil { - return nil, fmt.Errorf("failed to reveal mnemonic: %w", err) + if opt.Mnemonic == "" { + fs.Debugf(nil, "No stored mnemonic, performing login with email/password") + cfg := config.NewDefaultToken("") + loginResp, err := auth.DoLogin(ctx, cfg, opt.Email, opt.Pass, opt.TwoFA) + if err != nil { + return nil, fmt.Errorf("couldn't login: %w", err) + } + m.Set("mnemonic", obscure.MustObscure(loginResp.User.Mnemonic)) + opt.Mnemonic = loginResp.User.Mnemonic + + oauthToken, err := jwtToOAuth2Token(loginResp.NewToken) + if err != nil { + return nil, fmt.Errorf("failed to parse token: %w", err) + } + err = oauthutil.PutToken(name, m, oauthToken, true) + if err != nil { + return nil, fmt.Errorf("failed to save token: %w", err) + } + } else { + var err error + opt.Mnemonic, err = obscure.Reveal(opt.Mnemonic) + if err != nil { + return nil, fmt.Errorf("couldn't decrypt mnemonic: %w", err) + } } 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) + return nil, fmt.Errorf("failed to get token: %w", err) } oauthConfig := &oauthutil.Config{