Skip to content

Commit

Permalink
Merge pull request #55 from jetstack/disconnected-login
Browse files Browse the repository at this point in the history
Implement a disconnected login flow
  • Loading branch information
charlieegan3 authored Nov 15, 2022
2 parents 24fb1f9 + 98b1cf9 commit b104053
Show file tree
Hide file tree
Showing 2 changed files with 102 additions and 6 deletions.
82 changes: 80 additions & 2 deletions internal/auth/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
package auth

import (
"bufio"
"context"
_ "embed"
"encoding/json"
Expand All @@ -11,8 +12,10 @@ import (
"net/http"
"net/url"
"os"
"os/signal"
"path/filepath"
"strings"
"syscall"
"time"

"github.com/gofrs/uuid"
Expand Down Expand Up @@ -65,10 +68,10 @@ func GetOAuthURLAndState(conf *oauth2.Config) (string, string) {
return oAuthURL, state
}

// WaitForOAuthToken starts an HTTP server that listens for an inbound request providing the oauth2 token. This function
// WaitForOAuthTokenCallback starts an HTTP server that listens for an inbound request providing the oauth2 token. This function
// blocks until a valid token is obtained or the provided context is cancelled. The provided state value must match
// on the inbound request.
func WaitForOAuthToken(ctx context.Context, conf *oauth2.Config, state string) (*oauth2.Token, error) {
func WaitForOAuthTokenCallback(ctx context.Context, conf *oauth2.Config, state string) (*oauth2.Token, error) {
ctx, cancel := context.WithCancel(ctx)
defer cancel()

Expand Down Expand Up @@ -151,6 +154,81 @@ func WaitForOAuthToken(ctx context.Context, conf *oauth2.Config, state string) (
return nil, err
}

// WaitForOAuthTokenCommandLine waits for a user to enter a redirect URL, then extracts the code and state and requests a token
func WaitForOAuthTokenCommandLine(ctx context.Context, conf *oauth2.Config, state string) (*oauth2.Token, error) {
fmt.Fprintf(os.Stderr, "Enter the URL you were redirected to (http://localhost:9999...) and press enter\n")

signalChan := make(chan os.Signal, 1)
resultChan := make(chan string, 1)
errChan := make(chan error, 1)

signal.Notify(signalChan, os.Interrupt, syscall.SIGINT, syscall.SIGTERM)
go func() {
select {
case <-signalChan:
errChan <- fmt.Errorf("interrupted")
}
}()
go func() {
// read in the raw URL the user pastes in
buf := bufio.NewReader(os.Stdin)
rawURL, err := buf.ReadBytes('\n')
if err != nil {
errChan <- err
}
resultChan <- string(rawURL)
}()

var rawURL string
select {
case res := <-resultChan:
rawURL = res
case err := <-errChan:
return nil, err
}

// parse the callback URL to extract the code and state
parsedURL, err := url.Parse(strings.TrimSpace(string(rawURL)))
if err != nil {
return nil, fmt.Errorf("failed to parse query: %w", err)
}
query, err := url.ParseQuery(parsedURL.RawQuery)
if err != nil {
return nil, fmt.Errorf("failed to parse url query: %w", err)
}

// validate the state in the callback URL matches
if state != query.Get("state") {
return nil, fmt.Errorf("invalid state: %s != %s", state, query.Get("state"))
}

// fetch a token using the code from the parsed callback URL
token, err := conf.Exchange(ctx, query.Get("code"))
if err != nil {
return nil, fmt.Errorf("failed to exchange token: %w", err)
}

// make a request to the auth endpoint to validate the token we have received
req, err := http.NewRequest(http.MethodGet, "https://platform.jetstack.io/api/v1/auth", nil)
if err != nil {
return nil, fmt.Errorf("failed to create request to test token: %w", err)
}
resp, err := oauth2.NewClient(ctx, conf.TokenSource(ctx, token)).Do(req)
if err != nil {
return nil, fmt.Errorf("failed to test token: %w", err)
}
defer resp.Body.Close()

// validate that the response is a 200OK, as this is the only valid response
// when testing the token
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("failed to test token: unexpected response %s", resp.Status)
}

// return the token from conf.Exchange
return token, nil
}

// SaveOAuthToken writes the provided token to a JSON file in the user's config directory. This location changes based
// on the host operating system. See the documentation for os.UserConfigDir for specifics on where the token file will
// be placed.
Expand Down
26 changes: 22 additions & 4 deletions internal/command/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,7 @@ func authStatus() *cobra.Command {

func authLogin() *cobra.Command {
var credentials string
var disconnected bool

cmd := &cobra.Command{
Use: "login",
Expand All @@ -114,7 +115,7 @@ func authLogin() *cobra.Command {
if credentials != "" {
token, err = loginWithCredentials(ctx, oAuthConfig, credentials)
} else {
token, err = loginWithOAuth(ctx, oAuthConfig)
token, err = loginWithOAuth(ctx, oAuthConfig, disconnected)
}

if err != nil {
Expand Down Expand Up @@ -147,7 +148,13 @@ func authLogin() *cobra.Command {
&credentials,
"credentials",
os.Getenv("JSCTL_CREDENTIALS"),
"The location of a credentials file to use instead of the normal oauth login flow",
"The location of service account credentials file to use instead of the normal oauth login flow",
)
flags.BoolVar(
&disconnected,
"disconnected",
false,
"Use a disconnected login flow where browser and terminal are not running on the same machine",
)

return cmd
Expand All @@ -172,9 +179,20 @@ func authLogout() *cobra.Command {
}
}

func loginWithOAuth(ctx context.Context, oAuthConfig *oauth2.Config) (*oauth2.Token, error) {
func loginWithOAuth(ctx context.Context, oAuthConfig *oauth2.Config, disconnected bool) (*oauth2.Token, error) {
url, state := auth.GetOAuthURLAndState(oAuthConfig)

// disconnected can be set to true when the browser and terminal are not running
// on the same machine.
if disconnected {
fmt.Printf("Navigate to the URL below to login:\n%s\n", url)
token, err := auth.WaitForOAuthTokenCommandLine(ctx, oAuthConfig, state)
if err != nil {
return nil, fmt.Errorf("failed to obtain token: %w", err)
}
return token, nil
}

fmt.Println("Opening browser to:", url)

if err := webbrowser.Open(url); err != nil {
Expand All @@ -183,7 +201,7 @@ func loginWithOAuth(ctx context.Context, oAuthConfig *oauth2.Config) (*oauth2.To
fmt.Println("You will be taken to your browser for authentication")
}

token, err := auth.WaitForOAuthToken(ctx, oAuthConfig, state)
token, err := auth.WaitForOAuthTokenCallback(ctx, oAuthConfig, state)
if err != nil {
return nil, err
}
Expand Down

0 comments on commit b104053

Please sign in to comment.