diff --git a/go.sum b/go.sum index ff5a244..eb4d1a1 100644 --- a/go.sum +++ b/go.sum @@ -55,3 +55,5 @@ google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp0 google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= google.golang.org/protobuf v1.28.0 h1:w43yiav+6bVFTBQFZX0r7ipe9JQ1QsbMgHwbBziscLw= google.golang.org/protobuf v1.28.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/non_interactive.go b/non_interactive.go new file mode 100644 index 0000000..ac5b9cc --- /dev/null +++ b/non_interactive.go @@ -0,0 +1,48 @@ +package oauth2cli + +import ( + "bufio" + "encoding/base64" + "encoding/json" + "fmt" + "golang.org/x/sync/errgroup" + "os" + "strings" +) + +func receiveCodeViaUserInput(c *Config) (*OAuth2ConfigAndCode, error) { + var userInput *OAuth2ConfigAndCode + + var eg errgroup.Group + + eg.Go(func() error { + buf := bufio.NewReader(os.Stdin) + fmt.Print(c.NonInteractivePromptText) + input, err := buf.ReadBytes('\n') + if err != nil { + return err + } else { + cleanedInput := strings.TrimSuffix(string(input), "\n") + decoded, err := base64.StdEncoding.DecodeString(cleanedInput) + if err != nil { + return err + } + + configAndCode := OAuth2ConfigAndCode{} + err = json.Unmarshal(decoded, &configAndCode) + if err != nil { + return err + } + + userInput = &configAndCode + + return nil + } + }) + + if err := eg.Wait(); err != nil { + return nil, err + } + + return userInput, nil +} diff --git a/oauth2cli.go b/oauth2cli.go index 578ecd3..86cd241 100644 --- a/oauth2cli.go +++ b/oauth2cli.go @@ -4,10 +4,12 @@ package oauth2cli import ( "context" + "encoding/base64" + "encoding/json" "fmt" + "github.com/int128/oauth2cli/oauth2params" "net/http" - "github.com/int128/oauth2cli/oauth2params" "golang.org/x/oauth2" ) @@ -94,6 +96,11 @@ type Config struct { // Redirect URL upon failed login FailureRedirectURL string + // Allow non-interactive login flows (headless machines without a browser available) + NonInteractive bool + // Instructions to print to stdout for non-interactive flows + NonInteractivePromptText string + // Logger function for debug. Logf func(format string, args ...interface{}) } @@ -127,6 +134,9 @@ func (c *Config) validateAndSetDefaults() error { (c.SuccessRedirectURL == "" && c.FailureRedirectURL != "") { return fmt.Errorf("when using success and failure redirect URLs, set both URLs") } + if c.NonInteractivePromptText == "" { + c.NonInteractivePromptText = "Please enter a valid authorization code flow code: " + } if c.Logf == nil { c.Logf = func(string, ...interface{}) {} } @@ -138,18 +148,33 @@ func (c *Config) validateAndSetDefaults() error { // // This performs the following steps: // -// 1. Start a local server at the port. -// 2. Open a browser and navigate it to the local server. -// 3. Wait for the user authorization. -// 4. Receive a code via an authorization response (HTTP redirect). -// 5. Exchange the code and a token. -// 6. Return the code. -// +// 1. Start a local server at the port. +// 2. Open a browser and navigate it to the local server. +// 3. Wait for the user authorization. +// 4. Receive a code via an authorization response (HTTP redirect). +// 5. Exchange the code and a token. +// 6. Return the code. func GetToken(ctx context.Context, c Config) (*oauth2.Token, error) { if err := c.validateAndSetDefaults(); err != nil { return nil, fmt.Errorf("invalid config: %w", err) } - code, err := receiveCodeViaLocalServer(ctx, &c) + var code string + var err error + + if c.NonInteractive { + var codeAndConfig *OAuth2ConfigAndCode + codeAndConfig, err = receiveCodeViaUserInput(&c) + + if err != nil { + return nil, fmt.Errorf("error parsing user input: %w", err) + } + + code = (*codeAndConfig).Code + c.OAuth2Config = (*codeAndConfig).OAuth2Config + } else { + code, err = receiveCodeViaLocalServer(ctx, &c) + } + if err != nil { return nil, fmt.Errorf("authorization error: %w", err) } @@ -160,3 +185,40 @@ func GetToken(ctx context.Context, c Config) (*oauth2.Token, error) { } return token, nil } + +type OAuth2ConfigAndCode struct { + OAuth2Config oauth2.Config + Code string +} + +// GetCodeAndConfig cuts the authorization code flow in half. This allows for the +// login process to be performed on headless machines that do not have access to a +// browser by doing the interactive login on a machine that does have access to +// a browser, and copying the result onto the headless machine. +// The response of this function is a JSON that includes both the used +// OAuth2Config and the authorization code, as a base64 encoded string. +// This is the same string that receiveCodeViaUserInput expects +func GetCodeAndConfig(ctx context.Context, c Config) (*string, error) { + if err := c.validateAndSetDefaults(); err != nil { + return nil, fmt.Errorf("invalid config: %w", err) + } + + code, err := receiveCodeViaLocalServer(ctx, &c) + if err != nil { + return nil, err + } + + configAndCode := OAuth2ConfigAndCode{ + OAuth2Config: c.OAuth2Config, + Code: code, + } + + bytes, unmarshalErr := json.Marshal(configAndCode) + jsonCodeAndConfig := base64.StdEncoding.EncodeToString(bytes) + + if unmarshalErr != nil { + return nil, unmarshalErr + } + + return &jsonCodeAndConfig, nil +}