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
66 changes: 65 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ Realtime: Realtime gateway API service
* [Shell Completion](#shell-completion)
* [CLI Example Usage](#cli-example-usage)
* [Authentication](#authentication)
* [Browser sign-in (OAuth2)](#browser-sign-in-oauth2)
* [Available Commands](#available-commands)
* [Request Body Input](#request-body-input)
* [Server Selection](#server-selection)
Expand Down Expand Up @@ -75,7 +76,7 @@ Download pre-built binaries for your platform from the [releases page](https://g

## Quickstart: zero to a talking agent

Runnable end to end by a person or by a coding agent. You need an access token and [`jq`](https://jqlang.org) (used only to pull IDs out of the JSON responses). Create a **personal access token** in Voiceflow under **Settings → Access tokens** (tokens start with `vfp_`; they expire, default 30 days), then:
Runnable end to end by a person or by a coding agent. You need credentials and [`jq`](https://jqlang.org) (used only to pull IDs out of the JSON responses). At a terminal, `vf auth login` signs you in through the browser and stores the session for you (see [Browser sign-in](#browser-sign-in-oauth2)). For scripts, CI, and coding agents, create a **personal access token** in Voiceflow under **Settings → Access tokens** (tokens start with `vfp_`; they expire, default 30 days), then:

```bash
export VF_TOKEN=vfp_... # every command also accepts --token
Expand Down Expand Up @@ -210,6 +211,69 @@ vf configure
Configuration is stored in `~/.config/vf/config.yaml`.
<!-- End Authentication [security] -->

<!-- The section below is hand-written; it sits outside the generated markers above so `speakeasy run` does not overwrite it. -->
## Browser sign-in (OAuth2)

Instead of pasting a personal access token, sign in through the browser:

```bash
vf auth login
```

This runs an OAuth2 authorization code flow with PKCE against
`https://auth-api.voiceflow.com`: the CLI opens your browser, receives the
authorization code on a loopback listener (`http://127.0.0.1:5133x/oauth/callback`),
and exchanges it for an access and refresh token. Later commands refresh the
access token automatically as it nears expiry, so the session keeps working
without another sign-in.

| Command | What it does |
|---------|--------------|
| `vf auth login` | Sign in through the browser |
| `vf auth login --no-browser` | Print the sign-in URL instead of opening a browser (headless machines) |
| `vf auth login --token <value>` | Store a bearer token non-interactively, no browser |
| `vf auth login --manual` | Prompt for a bearer token instead of signing in |
| `vf auth whoami` | Show the configured credential and the current session |
| `vf auth logout` | Clear the session and every stored credential |

Other flags: `--scope` (repeatable) requests specific scopes instead of the
ones the authorization server advertises, and `--login-timeout` bounds the wait
for the browser (default 5m).

**Signing in over SSH.** `--no-browser` only stops the CLI from launching a
browser; the callback listener still runs on the *remote* host's loopback
interface, so a URL opened on your workstation would redirect to your
workstation instead. Forward the callback port to the remote host first:

```bash
ssh -L 51330:127.0.0.1:51330 remote-host
vf auth login --no-browser # then open the printed URL locally
```

The CLI binds the first free port of 51330-51333, so forward the port you
intend it to use and keep the others occupied or unforwarded.

**Where the tokens live.** Access and refresh tokens go to the OS keychain —
macOS Keychain, Windows Credential Manager, Linux Secret Service — under
separate entries. On a machine with no keychain (headless Linux, containers)
they fall back to `~/.config/vf/oauth.json`, written with owner-only
permissions. Session metadata that is not secret (issuer, scopes, expiry, and
the cached public client registration) is always kept in
`~/.config/vf/oauth.json` and `~/.config/vf/oauth-client.json`.

**Precedence.** An active browser session outranks a token stored by
`vf configure` or `vf auth login --token`, because it is refreshed on demand.
A `--token` flag or a `VF_TOKEN` environment variable still wins over both, so
CI keeps behaving exactly as before and agent mode is unaffected —
`vf auth login` is interactive and stays blocked there.

**Pointing at another authorization server.** `VF_OAUTH_ISSUER`,
`VF_OAUTH_CLIENT_ID`, `VF_OAUTH_RESOURCE`, `VF_OAUTH_SCOPES`, and
`VF_OAUTH_REDIRECT_URI` override the defaults. With no `VF_OAUTH_CLIENT_ID`
set, the CLI registers itself as a public client through the authorization
server's dynamic client registration endpoint and caches the resulting
`client_id` for later logins.

<!-- Start Available Commands [operations] -->
## Available Commands

Expand Down
2 changes: 1 addition & 1 deletion docs/vf_auth.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,6 @@ Subcommands:
### SEE ALSO

* [vf](vf.md) - Realtime: Realtime gateway API service
* [vf auth login](vf_auth_login.md) - Interactively configure authentication credentials
* [vf auth login](vf_auth_login.md) - Sign in through the browser or configure credentials
* [vf auth logout](vf_auth_logout.md) - Clear all stored authentication credentials
* [vf auth whoami](vf_auth_whoami.md) - Display current authentication configuration
21 changes: 14 additions & 7 deletions docs/vf_auth_login.md
Original file line number Diff line number Diff line change
@@ -1,15 +1,18 @@
## vf auth login

Interactively configure authentication credentials
Sign in through the browser or configure credentials

### Synopsis

Interactively configure authentication credentials for vf.
Secret credentials are stored in the OS keychain when available,
with a config file fallback.
Sign in to Voiceflow.

All fields are optional — press Enter to skip any field you don't need.
Use the configure command for both authentication and global parameters.
By default this opens your browser to complete an OAuth2 authorization code
flow, then stores the resulting access and refresh tokens in the OS keychain
when available, with an owner-only file fallback.

Use --token to store a bearer token non-interactively, or --manual to be
prompted for one. Use the configure command for both authentication and global
parameters.

```
vf auth login [flags]
Expand All @@ -18,7 +21,11 @@ vf auth login [flags]
### Options

```
-h, --help help for login
-h, --help help for login
--login-timeout duration How long to wait for the browser to complete sign-in (default 5m0s)
--manual Prompt for a bearer token instead of signing in through the browser
--no-browser Print the sign-in URL instead of opening a browser
--scope stringArray OAuth scope to request (repeatable). Defaults to the scopes the authorization server advertises.
```

### Options inherited from parent commands
Expand Down
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ require (
github.com/spf13/pflag v1.0.9
github.com/stretchr/testify v1.9.0
github.com/zalando/go-keyring v0.2.6
golang.org/x/sys v0.41.0
golang.org/x/term v0.40.0
gopkg.in/yaml.v3 v3.0.1
)
Expand Down Expand Up @@ -51,6 +52,5 @@ require (
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/sync v0.11.0 // indirect
golang.org/x/sys v0.41.0 // indirect
golang.org/x/text v0.18.0 // indirect
)
37 changes: 29 additions & 8 deletions internal/cli/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (
"github.com/charmbracelet/lipgloss"
"github.com/spf13/cobra"
"github.com/voiceflow/cli/internal/config"
"github.com/voiceflow/cli/internal/oauth"
"github.com/voiceflow/cli/internal/output"
"golang.org/x/term"
"os"
Expand All @@ -28,17 +29,22 @@ Subcommands:
}
parent.AddCommand(authCmd)

authCmd.AddCommand(&cobra.Command{
loginCmd := &cobra.Command{
Use: "login",
Short: "Interactively configure authentication credentials",
Long: `Interactively configure authentication credentials for vf.
Secret credentials are stored in the OS keychain when available,
with a config file fallback.
Short: "Sign in through the browser or configure credentials",
Long: `Sign in to Voiceflow.

By default this opens your browser to complete an OAuth2 authorization code
flow, then stores the resulting access and refresh tokens in the OS keychain
when available, with an owner-only file fallback.

All fields are optional — press Enter to skip any field you don't need.
Use the configure command for both authentication and global parameters.`,
Use --token to store a bearer token non-interactively, or --manual to be
prompted for one. Use the configure command for both authentication and global
parameters.`,
RunE: runAuthLoginCmd,
})
}
oauth.AddLoginFlags(loginCmd) // browser login flags; see internal/oauth
authCmd.AddCommand(loginCmd)

authCmd.AddCommand(&cobra.Command{
Use: "whoami",
Expand Down Expand Up @@ -83,6 +89,12 @@ func runAuthLoginCmd(cmd *cobra.Command, args []string) error {
)
}

// Browser-based OAuth2 login is the default. --token, --manual, and
// --no-interactive fall through to the token-entry paths below.
if oauth.ShouldUseBrowserLogin(cmd) {
return oauth.RunLogin(cmd)
}

cfg := config.GetConfig()
if cfg == nil {
cfg = &config.Config{}
Expand Down Expand Up @@ -161,11 +173,20 @@ func runAuthLogoutCmd(cmd *cobra.Command, args []string) error {
}
cfg.Security.Token = ""

// Clear the stored OAuth session as well; see internal/oauth.
hadSession, err := oauth.RunLogout()
if err != nil {
return fmt.Errorf("failed to clear OAuth session: %w", err)
}

if err := config.SaveConfig(cfg); err != nil {
return fmt.Errorf("failed to save configuration: %w", err)
}

out := cmd.OutOrStdout()
if hadSession {
fmt.Fprintln(out, "Signed out of the OAuth session.")
}
fmt.Fprintln(out, "All authentication credentials have been cleared.")
fmt.Fprintf(out, "Configuration saved to %s\n", config.GetConfigPath())
return nil
Expand Down
3 changes: 3 additions & 0 deletions internal/cli/whoami.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"fmt"
"github.com/spf13/cobra"
"github.com/voiceflow/cli/internal/config"
"github.com/voiceflow/cli/internal/oauth"
"github.com/voiceflow/cli/internal/usage"
)

Expand Down Expand Up @@ -51,5 +52,7 @@ func runWhoamiCmd(cmd *cobra.Command, args []string) error {
fmt.Fprintf(out, " --%-25s [%-7s] %s\n", "token", source, maskSecret(value))
}

oauth.WriteStatus(out) // browser login session; see internal/oauth

return nil
}
31 changes: 27 additions & 4 deletions internal/client/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
package client

import (
"errors"
"fmt"
"net/http"
"strconv"
Expand All @@ -12,6 +13,7 @@ import (
"github.com/spf13/cobra"
"github.com/voiceflow/cli/internal/config"
"github.com/voiceflow/cli/internal/flagutil"
"github.com/voiceflow/cli/internal/oauth"
"github.com/voiceflow/cli/internal/sdk"
"github.com/voiceflow/cli/internal/sdk/models/components"
"github.com/voiceflow/cli/internal/testclient"
Expand All @@ -22,7 +24,11 @@ import (
// retry configuration, timeout, and test client injection.
func NewClient(cmd *cobra.Command) (*sdk.VoiceflowSDK, error) {
var sdkOpts []sdk.SDKOption
sdkOpts = append(sdkOpts, sdk.WithSecurity(buildGlobalSecurity(cmd)))
security, err := buildGlobalSecurity(cmd)
if err != nil {
return nil, err
}
sdkOpts = append(sdkOpts, sdk.WithSecurity(security))
if serverURL := resolveStringFlag(cmd, "server-url"); serverURL != "" {
sdkOpts = append(sdkOpts, sdk.WithServerURL(serverURL))
} else if serverFlag, _ := flagutil.GetStringFlag(cmd, "server"); serverFlag != "" {
Expand Down Expand Up @@ -62,12 +68,29 @@ func resolveStringFlag(cmd *cobra.Command, name string) string {

// buildGlobalSecurity reads security credentials from flags, env vars, and config file.
// Priority: flag > env var > config file.
func buildGlobalSecurity(cmd *cobra.Command) components.Security {
func buildGlobalSecurity(cmd *cobra.Command) (components.Security, error) {
// Resolve security credentials: flag > env var > keyring > config file
token, _ := config.ResolveSecurityCredential(cmd, "token")
token, source := config.ResolveSecurityCredential(cmd, "token")

// An OAuth session from 'vf auth login' outranks a stored static token —
// it is refreshed on demand — while an explicit flag or environment
// variable still wins, so CI can override it. See internal/oauth.
if source != "flag" && source != "env" {
sessionToken, err := oauth.AccessToken(cmd.Context())
// Not being signed in is ordinary; a stored session that could not be
// renewed is not. Reporting that beats sending the request with a stale
// or missing token and surfacing an unrelated API auth failure.
if err != nil && !errors.Is(err, oauth.ErrNoSession) {
return components.Security{}, err
}
if sessionToken != "" {
token = sessionToken
}
}

globalSecurity := components.Security{}
if token != "" {
globalSecurity.Token = token
}
return globalSecurity
return globalSecurity, nil
}
54 changes: 54 additions & 0 deletions internal/oauth/browser.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
package oauth

import (
"fmt"
"os/exec"
"runtime"
"strings"
)

// browserOpener is swapped out in tests.
var browserOpener = openBrowser

// openBrowser launches the platform's default browser on url. The URL is
// always passed as a separate argument, never interpolated into a shell
// command, so its query string cannot be reinterpreted by a shell.
func openBrowser(url string) error {
var name string
var args []string

switch runtime.GOOS {
case "darwin":
name, args = "open", []string{url}
case "windows":
// rundll32 hands the URL to the registered protocol handler without
// going through cmd.exe, where '&' would split the command.
name, args = "rundll32", []string{"url.dll,FileProtocolHandler", url}
default:
// xdg-open is the freedesktop standard; the others cover minimal
// installs and WSL.
for _, candidate := range []string{"xdg-open", "wslview", "gio", "sensible-browser", "x-www-browser"} {
if path, err := exec.LookPath(candidate); err == nil {
name = path
if candidate == "gio" {
args = []string{"open", url}
} else {
args = []string{url}
}
break
}
}
if name == "" {
return fmt.Errorf("no browser launcher found (tried xdg-open, wslview, gio)")
}
}

cmd := exec.Command(name, args...)
if err := cmd.Start(); err != nil {
return fmt.Errorf("launch browser via %s: %w", strings.TrimSpace(name), err)
}
// The launcher exits immediately after handing the URL to the browser;
// reap it so it does not linger as a zombie for the CLI's lifetime.
go func() { _ = cmd.Wait() }()
return nil
}
Loading
Loading