Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support for non-interactive logins on headless machines #116

Open
wants to merge 6 commits into
base: master
Choose a base branch
from
Open
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
2 changes: 2 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -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=
48 changes: 48 additions & 0 deletions non_interactive.go
Original file line number Diff line number Diff line change
@@ -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
}
80 changes: 71 additions & 9 deletions oauth2cli.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)

Expand Down Expand Up @@ -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{})
}
Expand Down Expand Up @@ -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{}) {}
}
Expand All @@ -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)
}
Expand All @@ -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
}