Skip to content
Draft
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
88 changes: 73 additions & 15 deletions cmd/entire/cli/auth/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"strings"
"time"

"github.com/entireio/auth-go/authcode"
"github.com/entireio/auth-go/deviceflow"
"github.com/entireio/auth-go/tokens"
"github.com/entireio/cli/cmd/entire/cli/api"
Expand Down Expand Up @@ -41,7 +42,8 @@ type DeviceAuthPoll struct {
// (ENTIRE_AUTH_PROVIDER_VERSION wins, then split-host auto-detect,
// then v1 fallback).
type Client struct {
inner *deviceflow.Client
inner *deviceflow.Client
browser *authcode.Client
}

// NewClient constructs a Client targeting the active provider version.
Expand All @@ -61,20 +63,76 @@ func NewClient(httpClient *http.Client, allowInsecureHTTP bool) *Client {
if httpClient != nil {
transport = httpClient.Transport
}
return &Client{inner: &deviceflow.Client{
Transport: transport,
BaseURL: issuer,
ClientID: p.ClientID,
// offline_access asks the authorization server for a refresh token.
// The server only mints one when it's requested (it's client-gated),
// so without this the device login is access-token-only and silent
// refresh is impossible.
Scope: "cli offline_access",
UserAgent: p.ClientID,
DeviceCodePath: p.DeviceCodePath,
TokenPath: p.TokenPath,
AllowInsecureHTTP: allowInsecureHTTP || isLoopbackHTTP(issuer),
}}
// offline_access asks the authorization server for a refresh token.
// The server only mints one when it's requested (it's client-gated),
// so without this the login is access-token-only and silent refresh is
// impossible. Both flows request it identically.
const scope = "cli offline_access"
allowHTTP := allowInsecureHTTP || isLoopbackHTTP(issuer)
return &Client{
inner: &deviceflow.Client{
Transport: transport,
BaseURL: issuer,
ClientID: p.ClientID,
Scope: scope,
UserAgent: p.ClientID,
DeviceCodePath: p.DeviceCodePath,
TokenPath: p.TokenPath,
AllowInsecureHTTP: allowHTTP,
},
browser: &authcode.Client{
Transport: transport,
BaseURL: issuer,
ClientID: p.ClientID,
Scope: scope,
UserAgent: p.ClientID,
AuthorizePath: p.AuthorizePath,
TokenPath: p.TokenPath,
AllowInsecureHTTP: allowHTTP,
},
}
}

// BrowserAuthFlow is one in-progress loopback authorization-code login. It
// wraps an authcode.Flow, flattening the TokenSet to the (access, refresh)
// pair login.go persists — mirroring how PollDeviceAuth flattens the
// device-flow result. login.go depends on a small local interface that this
// concrete type satisfies, so it can fake the flow in tests.
type BrowserAuthFlow struct {
inner *authcode.Flow
}

// AuthorizationURL is the URL to open in the user's browser.
func (f *BrowserAuthFlow) AuthorizationURL() string { return f.inner.AuthorizationURL }

// Wait blocks until the browser is redirected to the loopback listener,
// returning the authorization code.
func (f *BrowserAuthFlow) Wait(ctx context.Context) (string, error) {
return f.inner.Wait(ctx) //nolint:wrapcheck // shim preserves the lib's wrapped errors verbatim for errors.Is
}

// Exchange redeems code for access + refresh tokens.
func (f *BrowserAuthFlow) Exchange(ctx context.Context, code string) (accessToken, refreshToken string, err error) {
ts, err := f.inner.Exchange(ctx, code)
if err != nil {
return "", "", err //nolint:wrapcheck // shim returns authcode errors verbatim so callers can errors.Is on sentinels
}
return ts.AccessToken, ts.RefreshToken, nil
}

// Close tears down the loopback listener. Safe to call after Wait.
func (f *BrowserAuthFlow) Close() error {
return f.inner.Close() //nolint:wrapcheck // shutdown error is best-effort; caller logs at most
}

// StartBrowserAuth begins the loopback authorization-code flow: it binds a
// local listener and returns a flow carrying the browser URL to open.
func (c *Client) StartBrowserAuth(ctx context.Context) (*BrowserAuthFlow, error) {
f, err := c.browser.Start(ctx)
if err != nil {
return nil, err //nolint:wrapcheck // shim returns authcode errors verbatim so callers can errors.Is on sentinels
}
return &BrowserAuthFlow{inner: f}, nil
}

// BaseURL returns the issuer base URL this client talks to.
Expand Down
9 changes: 7 additions & 2 deletions cmd/entire/cli/auth/provider.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ const ProviderVersionEnvVar = "ENTIRE_AUTH_PROVIDER_VERSION"
type Provider struct {
ClientID string
DeviceCodePath string
AuthorizePath string
TokenPath string
STSPath string
}
Expand All @@ -32,6 +33,7 @@ var providers = map[string]Provider{
"v1": { //nolint:gosec // OAuth client_id and endpoint paths, not credentials
ClientID: "entire-cli",
DeviceCodePath: "/oauth/device/code",
AuthorizePath: "/oauth/authorize",
TokenPath: "/oauth/token",
},
"v2": { //nolint:gosec // OAuth client_id and endpoint paths, not credentials
Expand All @@ -42,8 +44,11 @@ var providers = map[string]Provider{
// exchange at the shared /oauth/token endpoint.
ClientID: "entire-cli",
DeviceCodePath: "/device_authorization",
TokenPath: "/oauth/token",
STSPath: "/oauth/token",
// OIDC-standard authorization_endpoint. Verified against
// us.auth.entire.io's /.well-known/openid-configuration.
AuthorizePath: "/authorize",
TokenPath: "/oauth/token",
STSPath: "/oauth/token",
},
}

Expand Down
97 changes: 94 additions & 3 deletions cmd/entire/cli/integration_test/login_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import (
"io"
"net/http"
"net/http/httptest"
"net/url"
"path/filepath"
"strings"
"sync"
Expand Down Expand Up @@ -186,29 +187,94 @@ func TestLogin_DeniedFlow(t *testing.T) {
}
}

// TestLogin_BrowserFlow_SavesToken drives the loopback authorization-code
// flow end to end: ENTIRE_TEST_TTY=1 forces the interactive (browser)
// default, openBrowser reports failure under test (no usable browser on a
// headless host) so the flow prints the fallback URL, and the test plays
// the role of the browser by parsing that URL and GETting the loopback
// callback with a code + the state from it.
func TestLogin_BrowserFlow_SavesToken(t *testing.T) {
t.Parallel()

server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodPost && r.URL.Path == "/oauth/token" {
if err := r.ParseForm(); err != nil {
t.Errorf("parse token form: %v", err)
}
if got := r.PostForm.Get("grant_type"); got != "authorization_code" {
t.Errorf("grant_type = %q, want authorization_code", got)
}
if r.PostForm.Get("code_verifier") == "" {
t.Error("token request missing code_verifier")
}
writeJSON(t, w, http.StatusOK, map[string]any{
"access_token": "browser-token", "token_type": "Bearer", "expires_in": 3600, "scope": "cli offline_access",
})
return
}
http.NotFound(w, r)
}))
defer server.Close()

proc := startLoginProcess(t, server.URL, []string{"ENTIRE_TEST_TTY=1"}, "login", "--insecure-http-auth")

authURL := waitForBrowserPrompt(t, proc.stdout)
u, err := url.Parse(authURL)
if err != nil {
t.Fatalf("parse authorization URL %q: %v", authURL, err)
}
q := u.Query()
redirectURI, state := q.Get("redirect_uri"), q.Get("state")
if redirectURI == "" || state == "" {
t.Fatalf("authorization URL missing redirect_uri/state: %s", authURL)
}

cbResp, err := http.Get(redirectURI + "?" + url.Values{"code": {"auth-code-1"}, "state": {state}}.Encode()) //nolint:noctx // test
if err != nil {
t.Fatalf("GET loopback callback: %v", err)
}
_ = cbResp.Body.Close()

output, waitErr := proc.wait()
if waitErr != nil {
t.Fatalf("login command failed: %v\nOutput:\n%s", waitErr, output)
}
if !strings.Contains(output, "Login complete.") {
t.Fatalf("output missing login complete message:\n%s", output)
}
}

type loginProcess struct {
stdout *bufio.Reader
waitFn func() (string, error)
}

func runLoginProcess(t *testing.T, apiBaseURL string) *loginProcess {
t.Helper()
// No ENTIRE_TEST_TTY: NonInteractive + non-interactive default routes
// `entire login` to the device-code flow.
return startLoginProcess(t, apiBaseURL, nil, "login", "--insecure-http-auth")
}

func startLoginProcess(t *testing.T, apiBaseURL string, extraEnv []string, args ...string) *loginProcess {
t.Helper()

env := NewTestEnv(t)

cmd := execx.NonInteractive(context.Background(), getTestBinary(), "login", "--insecure-http-auth")
cmd := execx.NonInteractive(context.Background(), getTestBinary(), args...)
cmd.Dir = env.RepoDir
cmd.Env = append(testutil.GitIsolatedEnv(),
"ENTIRE_TEST_CLAUDE_PROJECT_DIR="+env.ClaudeProjectDir,
"ENTIRE_TEST_GEMINI_PROJECT_DIR="+env.GeminiProjectDir,
"ENTIRE_TEST_OPENCODE_PROJECT_DIR="+env.OpenCodeProjectDir,
"ENTIRE_API_BASE_URL="+apiBaseURL,
// AuthBaseURL no longer inherits from BaseURL; pin both at the test
// server so the device flow stays in-process instead of reaching
// out to the production us.auth.entire.io default.
// server so the flow stays in-process instead of reaching out to the
// production us.auth.entire.io default.
"ENTIRE_AUTH_BASE_URL="+apiBaseURL,
"ENTIRE_TEST_AUTH_STORE_FILE="+filepath.Join(env.RepoDir, ".entire-test-auth-store.json"),
)
cmd.Env = append(cmd.Env, extraEnv...)

stdoutPipe, err := cmd.StdoutPipe()
if err != nil {
Expand Down Expand Up @@ -268,6 +334,31 @@ func waitForLoginPrompt(t *testing.T, stdout *bufio.Reader) (string, string) {
return "", ""
}

// waitForBrowserPrompt reads login stdout until it finds the
// "Open this URL in your browser to sign in: <url>" fallback line and
// returns the URL. Under test openBrowser reports failure (no usable
// browser on a headless host), so the browser flow always prints this
// fallback — which is how the test recovers the ephemeral callback URL.
func waitForBrowserPrompt(t *testing.T, stdout *bufio.Reader) string {
t.Helper()

const prefix = "Open this URL in your browser to sign in: "
deadline := time.Now().Add(10 * time.Second)
for time.Now().Before(deadline) {
line, err := stdout.ReadString('\n')
if err != nil {
t.Fatalf("read login output: %v", err)
}
line = strings.TrimSpace(line)
if after, ok := strings.CutPrefix(line, prefix); ok {
return after
}
}

t.Fatal("timed out waiting for browser login prompt")
return ""
}
Comment thread
toothbrush marked this conversation as resolved.

func writeJSON(t *testing.T, w http.ResponseWriter, status int, body map[string]any) {
t.Helper()
w.Header().Set("Content-Type", "application/json")
Expand Down
Loading
Loading