Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
160 changes: 0 additions & 160 deletions backend/internxt/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,6 @@ import (
"encoding/hex"
"errors"
"fmt"
"net"
"net/http"
"time"

"github.com/golang-jwt/jwt/v5"
Expand All @@ -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
Expand Down
168 changes: 73 additions & 95 deletions backend/internxt/internxt.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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{
Expand Down