Skip to content

Commit

Permalink
Merge pull request #232 from overmindtech/auth-view
Browse files Browse the repository at this point in the history
authenticate tea
  • Loading branch information
getinnocuous authored Apr 5, 2024
2 parents 72a3ff6 + 7c5d411 commit 77818f8
Show file tree
Hide file tree
Showing 4 changed files with 306 additions and 43 deletions.
4 changes: 2 additions & 2 deletions .air.toml
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ tmp_dir = "tmp"
[build]
args_bin = []
bin = "./tmp/main"
cmd = "go generate ./... && go build -o ./tmp/main main.go"
cmd = "go generate ./... && go build -gcflags='all=-N -l' -o ./tmp/main main.go"
delay = 1000
exclude_dir = ["assets", "build", "tmp", "vendor", "test", "testdata"]
exclude_file = ["server/admin/assets/dist.css"]
Expand All @@ -19,7 +19,7 @@ exclude_regex = [
exclude_unchanged = false
follow_symlink = false
# contrary to other repos, this does wait for the debugger to attach, as cli processes are very shortlived
full_bin = "dlv exec --accept-multiclient --log --headless --listen :9087 --api-version 2 ./tmp/main --"
full_bin = "dlv exec --accept-multiclient --headless --listen :9087 --api-version 2 ./tmp/main --"
include_dir = []
include_ext = ["go", "tpl", "tmpl", "templ", "html", "sql", "css", "md"]
include_file = ["sqlc.yaml"]
Expand Down
184 changes: 143 additions & 41 deletions cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,7 @@ import (
"strings"

"connectrpc.com/connect"
"github.com/charmbracelet/glamour"
"github.com/charmbracelet/huh/spinner"
tea "github.com/charmbracelet/bubbletea"
"github.com/google/uuid"
"github.com/overmindtech/cli/tracing"
"github.com/overmindtech/sdp-go"
Expand Down Expand Up @@ -274,6 +273,137 @@ func getAPIKeyToken(ctx context.Context, oi OvermindInstance, apiKey string) (*o
return token, nil
}

type statusMsg int

const (
PromptUser statusMsg = 0
WaitingForConfirmation statusMsg = 1
Authenticated statusMsg = 2
ErrorAuthenticating statusMsg = 3
)

type authenticateModel struct {
ctx context.Context

status statusMsg
err error
deviceCode *oauth2.DeviceAuthResponse
config oauth2.Config
token *oauth2.Token
}

func (m authenticateModel) Init() tea.Cmd {
return openBrowser(m.deviceCode.VerificationURI)
}

func (m authenticateModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {

case tea.KeyMsg:
switch msg.String() {
default:
{
if m.status == Authenticated {
return m, tea.Quit
}
}
case "ctrl+c", "q":
return m, tea.Quit
}

case *oauth2.Token:
{
m.status = Authenticated
m.token = msg
return m, nil
}

case statusMsg:
switch msg {
case PromptUser:
return m, openBrowser(m.deviceCode.VerificationURI)
case WaitingForConfirmation:
m.status = WaitingForConfirmation
return m, awaitToken(m.ctx, m.config, m.deviceCode)
case Authenticated:
case ErrorAuthenticating:
{
return m, nil
}
}

case browserOpenErrorMsg:
m.status = WaitingForConfirmation
return m, awaitToken(m.ctx, m.config, m.deviceCode)

case failedToAuthenticateErrorMsg:
m.err = msg.err
m.status = ErrorAuthenticating
return m, tea.Quit

case errMsg:
m.err = msg.err
return m, tea.Quit
}

return m, nil
}

func (m authenticateModel) View() string {
var output string
beginAuthMessage := `# Authenticate with a browser
Attempting to automatically open the SSO authorization page in your default browser.
If the browser does not open or you wish to use a different device to authorize this request, open the following URL:
%v
Then enter the code:
%v
`
prompt := fmt.Sprintf(beginAuthMessage, m.deviceCode.VerificationURI, m.deviceCode.UserCode)
output += markdownToString(prompt)
switch m.status {
case PromptUser:
// nothing here as PromptUser is the default
case WaitingForConfirmation:
sp := createSpinner()
output += sp.View() + " Waiting for confirmation..."
case Authenticated:
output = "✅ Authenticated successfully. Press any key to continue."
case ErrorAuthenticating:
output = "⛔️ Unable to authenticate. Try again."
}

return containerStyle.Render(output)
}

type errMsg struct{ err error }
type browserOpenErrorMsg struct{ err error }
type failedToAuthenticateErrorMsg struct{ err error }

func openBrowser(url string) tea.Cmd {
return func() tea.Msg {
err := browser.OpenURL(url)
if err != nil {
return browserOpenErrorMsg{err}
}
return WaitingForConfirmation
}
}

func awaitToken(ctx context.Context, config oauth2.Config, deviceCode *oauth2.DeviceAuthResponse) tea.Cmd {
return func() tea.Msg {
token, err := config.DeviceAccessToken(ctx, deviceCode)
if err != nil {
return failedToAuthenticateErrorMsg{err}
}

return token
}
}

// Gets a token from Oauth with the required scopes. This method will also cache
// that token locally for use later, and will use the cached token if possible
func getOauthToken(ctx context.Context, oi OvermindInstance, requiredScopes []string) (*oauth2.Token, error) {
Expand Down Expand Up @@ -332,48 +462,20 @@ func getOauthToken(ctx context.Context, oi OvermindInstance, requiredScopes []st
return nil, fmt.Errorf("error getting device code: %w", err)
}

r := NewTermRenderer()
prompt := `# Authenticate with a browser
m := authenticateModel{ctx: ctx, status: PromptUser, deviceCode: deviceCode, config: config}
authenticateProgram := tea.NewProgram(m)

Attempting to automatically open the SSO authorization page in your default browser.
If the browser does not open or you wish to use a different device to authorize this request, open the following URL:
%v
Then enter the code:
%v
`
prompt = fmt.Sprintf(prompt, deviceCode.VerificationURI, deviceCode.UserCode)
out, err := glamour.Render(prompt, "dark")
result, err := authenticateProgram.Run()
if err != nil {
panic(err)
}
fmt.Print(out)

err = browser.OpenURL(deviceCode.VerificationURIComplete)
if err != nil {
log.WithContext(ctx).WithError(err).Error("failed to execute local browser")
}

var token *oauth2.Token
_ = spinner.New().Title("Waiting for confirmation...").Action(func() {
token, err = config.DeviceAccessToken(ctx, deviceCode)
if err != nil {
log.WithContext(ctx).WithError(err).Errorf("Error exchanging Device Code for for access token")
err = fmt.Errorf("Error exchanging Device Code for for access token: %w", err)
return
}
}).Run()
if err != nil {
return nil, err
fmt.Println("Error running program:", err)
os.Exit(1)
}

out, err = r.Render("✅ Authenticated successfully")
if err != nil {
panic(err)
m, ok := result.(authenticateModel)
if !ok {
fmt.Println("Error running program: result is not authenticateModel")
os.Exit(1)
}
fmt.Println(out)

span := trace.SpanFromContext(ctx)
span.SetAttributes(attribute.Bool("ovm.cli.authenticated", true))
Expand All @@ -394,15 +496,15 @@ Then enter the code:
}

// Encode the token
err = json.NewEncoder(file).Encode(token)
err = json.NewEncoder(file).Encode(m.token)
if err != nil {
log.WithContext(ctx).WithError(err).Errorf("Failed to encode token file at %v", path)
}

log.WithContext(ctx).Debugf("Saved token to %v", path)
}

return token, nil
return m.token, nil
}

// ensureToken
Expand Down
36 changes: 36 additions & 0 deletions cmd/spinner.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
package cmd

import (
"github.com/charmbracelet/bubbles/spinner"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
)

type spinnerModel struct {
spinner spinner.Model
}

func createSpinner() spinnerModel {
s := spinner.New()
s.Spinner = spinner.Pulse
s.Style = lipgloss.NewStyle().Foreground(lipgloss.Color(ColorPalette.Light.BgMain))
return spinnerModel{spinner: s}
}

func (m spinnerModel) Init() tea.Cmd {
return m.spinner.Tick
}

func (m spinnerModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {

default:
var cmd tea.Cmd
m.spinner, cmd = m.spinner.Update(msg)
return m, cmd
}
}

func (m spinnerModel) View() string {
return m.spinner.View()
}
Loading

0 comments on commit 77818f8

Please sign in to comment.