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
9 changes: 7 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,15 +20,20 @@ Unit tests pass ≠ feature works. The TUI has UI state that tests cannot cover.

## Mandatory Pre-Push Requirement

**Before any `git push`, run tests and linters locally. They MUST pass.**
**Before any `git push`, run ALL of the following locally. They MUST pass.**

```bash
go test ./... # all tests must pass
golangci-lint run # linter must be clean
golangci-lint run # linter must be clean (matches CI config in .golangci.yml)
mise run build # binary must build
```

Equivalently: `mise run test:all` (runs lint + test + build).

CI runs `golangci-lint` v2 with `gocritic`, `gosec`, `errcheck`, and other strict
linters configured in `.golangci.yml`. If your local `golangci-lint` version is older,
it may miss issues that CI catches. Always verify lint passes before pushing.

Pushing code that breaks CI is unacceptable. No exceptions.

## Project
Expand Down
12 changes: 10 additions & 2 deletions cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,14 @@ import (
"github.com/gleanwork/glean-cli/internal/auth"
gleanClient "github.com/gleanwork/glean-cli/internal/client"
"github.com/gleanwork/glean-cli/internal/config"
"github.com/gleanwork/glean-cli/internal/debug"
"github.com/gleanwork/glean-cli/internal/tui"
"github.com/gleanwork/glean-cli/internal/update"
"github.com/spf13/cobra"
)

var authErrLog = debug.New("auth:login")

// cliVersion is set at startup via SetVersion from the ldflags-injected build version.
var cliVersion = "dev"

Expand Down Expand Up @@ -49,7 +52,9 @@ func NewCmdRoot() *cobra.Command {
Run 'glean --help' for other available commands.
`),
PersistentPreRun: func(cmd *cobra.Command, args []string) {
_ = verbosity // reserved for future debug logging
if verbosity > 0 {
debug.Enable()
}
},
PersistentPostRun: func(cmd *cobra.Command, args []string) {
// Skip update notice when the user is already running `glean update`.
Expand Down Expand Up @@ -183,7 +188,10 @@ func authError(err error) error {
fmt.Fprintf(os.Stderr, "Or set environment variables:\n")
fmt.Fprintf(os.Stderr, " export GLEAN_HOST=your-company-be.glean.com\n")
fmt.Fprintf(os.Stderr, " export GLEAN_API_TOKEN=your-token\n\n")
_ = err // underlying error logged above; don't expose internal message
authErrLog.Log("underlying auth error: %v", err)
if !authErrLog.Enabled() {
fmt.Fprintf(os.Stderr, " Tip: re-run with -v or GLEAN_DEBUG=auth:* for details\n\n")
}
return errSilent
}

Expand Down
88 changes: 76 additions & 12 deletions internal/auth/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,29 +16,44 @@ import (

"github.com/coreos/go-oidc/v3/oidc"
"github.com/gleanwork/glean-cli/internal/config"
"github.com/gleanwork/glean-cli/internal/debug"
"github.com/gleanwork/glean-cli/internal/httputil"
"github.com/int128/oauth2cli"
"github.com/pkg/browser"
"golang.org/x/oauth2"
)

var (
loginLog = debug.New("auth:login")
hostLog = debug.New("auth:resolve-host")
discoveryLog = debug.New("auth:discovery")
dcrLog = debug.New("auth:dcr")
tokenLog = debug.New("auth:token")
emailLog = debug.New("auth:email")
)

//go:embed success.html
var successHTML string

// Login performs the full OAuth 2.0 PKCE login flow for the configured Glean host.
// If the host is not configured, prompts for a work email and auto-discovers it.
// If the instance doesn't support OAuth, falls back to an inline API token prompt.
func Login(ctx context.Context) error {
loginLog.Log("starting login flow")

host, err := resolveHost(ctx)
if err != nil {
return err
}
loginLog.Log("host resolved: %s", host)

provider, endpoint, registrationEndpoint, err := discover(ctx, host)
if err != nil {
loginLog.Log("OAuth discovery failed, falling back to API token: %v", err)
fmt.Fprintf(os.Stderr, "\nOAuth discovery failed: %v\n", err)
return promptForAPIToken(host)
}
loginLog.Log("OAuth discovery succeeded: auth=%s token=%s registration=%s", endpoint.AuthURL, endpoint.TokenURL, registrationEndpoint)

// Find a free port for the local callback server.
// This must happen before DCR so we register the exact redirect URI
Expand All @@ -57,6 +72,7 @@ func Login(ctx context.Context) error {

verifier := oauth2.GenerateVerifier()
scopes := resolveScopes(provider)
loginLog.Log("requesting scopes: %v", scopes)

oauthCfg := oauth2.Config{
ClientID: clientID,
Expand Down Expand Up @@ -223,28 +239,43 @@ func EnsureAuth(ctx context.Context) error {
// a silent refresh and persists the new tokens before returning.
func LoadOAuthToken(host string) string {
tok, err := LoadTokens(host)
if err != nil || tok == nil {
if err != nil {
tokenLog.Log("load failed for %s: %v", host, err)
return ""
}
if tok == nil {
tokenLog.Log("no stored tokens for %s", host)
return ""
}
if !tok.IsExpired() {
tokenLog.Log("token valid (expires %s)", tok.Expiry.Format("15:04:05"))
return tok.AccessToken
}

// Token expired — attempt silent refresh.
if tok.RefreshToken != "" && tok.TokenEndpoint != "" {
if refreshed, err := refreshOAuthToken(host, tok); err == nil {
return refreshed.AccessToken
}
if tok.RefreshToken == "" || tok.TokenEndpoint == "" {
tokenLog.Log("token expired, cannot refresh (refresh_token=%t endpoint=%t)", tok.RefreshToken != "", tok.TokenEndpoint != "")
return ""
}
return ""
tokenLog.Log("token expired, refreshing via %s", tok.TokenEndpoint)
refreshed, err := refreshOAuthToken(host, tok)
if err != nil {
tokenLog.Log("refresh failed: %v", err)
return ""
}
tokenLog.Log("refreshed (new expiry=%s)", refreshed.Expiry.Format("15:04:05"))
return refreshed.AccessToken
}

// refreshOAuthToken exchanges a stored refresh_token for a new access token.
// The refreshed tokens are persisted to storage. Returns the updated StoredTokens.
func refreshOAuthToken(host string, tok *StoredTokens) (*StoredTokens, error) {
cl, err := LoadClient(host)
if err != nil || cl == nil {
tokenLog.Log("no stored OAuth client for %s (err=%v)", host, err)
return nil, fmt.Errorf("no stored OAuth client for %s — re-run 'glean auth login'", host)
}
tokenLog.Log("using stored client_id=%s for refresh", cl.ClientID)

oauthCfg := oauth2.Config{
ClientID: cl.ClientID,
Expand Down Expand Up @@ -279,7 +310,9 @@ func refreshOAuthToken(host string, tok *StoredTokens) (*StoredTokens, error) {
TokenType: newTok.TokenType,
TokenEndpoint: tok.TokenEndpoint,
}
_ = SaveTokens(host, stored)
if err := SaveTokens(host, stored); err != nil {
tokenLog.Log("persisting refreshed tokens failed: %v", err)
}
return stored, nil
}

Expand All @@ -289,8 +322,11 @@ func refreshOAuthToken(host string, tok *StoredTokens) (*StoredTokens, error) {
func resolveHost(ctx context.Context) (string, error) {
cfg, _ := config.LoadConfig()
if cfg != nil && cfg.GleanHost != "" {
return config.NormalizeHost(cfg.GleanHost), nil
host := config.NormalizeHost(cfg.GleanHost)
hostLog.Log("using configured host: %s", host)
return host, nil
}
hostLog.Log("no host configured, prompting for email")

fmt.Print("Enter your work email: ")
reader := bufio.NewReader(os.Stdin)
Expand All @@ -301,6 +337,7 @@ func resolveHost(ctx context.Context) (string, error) {
email = strings.TrimSpace(email)

fmt.Print("Looking up your Glean instance…")
hostLog.Log("looking up backend for email domain")
backendURL, err := LookupBackendURL(ctx, email)
if err != nil {
fmt.Println()
Expand All @@ -311,8 +348,11 @@ func resolveHost(ctx context.Context) (string, error) {
host := strings.TrimPrefix(backendURL, "https://")
host = strings.TrimPrefix(host, "http://")
host = strings.SplitN(host, "/", 2)[0]
hostLog.Log("discovered host: %s", host)

_ = config.SaveHostToFile(host)
if err := config.SaveHostToFile(host); err != nil {
hostLog.Log("best-effort host save failed: %v", err)
}
return host, nil
}

Expand All @@ -328,16 +368,21 @@ func resolveHost(ctx context.Context) (string, error) {
// provider is nil when only RFC 8414 discovery succeeded.
func discover(ctx context.Context, host string) (*oidc.Provider, oauth2.Endpoint, string, error) {
baseURL := "https://" + host
discoveryLog.Log("fetching protected resource metadata: %s", baseURL)
meta, err := fetchProtectedResource(ctx, baseURL)
if err != nil {
discoveryLog.Log("protected resource metadata failed: %v", err)
return nil, oauth2.Endpoint{}, "", err
}

issuer := meta.AuthorizationServers[0]
discoveryLog.Log("authorization server: %s", issuer)

// Try full OIDC discovery first (supports ID token, UserInfo).
discoveryLog.Log("trying OIDC discovery at %s", issuer)
provider, err := oidc.NewProvider(ctx, issuer)
if err == nil {
discoveryLog.Log("OIDC discovery succeeded")
// Still need registration_endpoint, which oidc.Provider doesn't expose.
authMeta, _ := fetchAuthServerMetadata(ctx, issuer)
regEndpoint := ""
Expand All @@ -346,15 +391,18 @@ func discover(ctx context.Context, host string) (*oidc.Provider, oauth2.Endpoint
}
return provider, provider.Endpoint(), regEndpoint, nil
}
discoveryLog.Log("OIDC discovery failed: %v, falling back to RFC 8414", err)

// Fall back to RFC 8414 auth server metadata.
authMeta, err := fetchAuthServerMetadata(ctx, issuer)
if err != nil {
return nil, oauth2.Endpoint{}, "", fmt.Errorf("OAuth discovery failed for %s: %w", issuer, err)
}
if authMeta.AuthorizationEndpoint == "" || authMeta.TokenEndpoint == "" {
discoveryLog.Log("RFC 8414 metadata incomplete: auth=%q token=%q", authMeta.AuthorizationEndpoint, authMeta.TokenEndpoint)
return nil, oauth2.Endpoint{}, "", fmt.Errorf("OAuth metadata missing required endpoints for %s", issuer)
}
discoveryLog.Log("RFC 8414 discovery succeeded: auth=%s token=%s", authMeta.AuthorizationEndpoint, authMeta.TokenEndpoint)

return nil, oauth2.Endpoint{
AuthURL: authMeta.AuthorizationEndpoint,
Expand All @@ -369,18 +417,25 @@ func discover(ctx context.Context, host string) (*oidc.Provider, oauth2.Endpoint
// Falls back to a static client configured via glean config --oauth-client-id.
func dcrOrStaticClient(ctx context.Context, host, registrationEndpoint, redirectURI string) (string, string, error) {
if registrationEndpoint != "" {
dcrLog.Log("registering client at %s with redirect %s", registrationEndpoint, redirectURI)
cl, err := registerClient(ctx, registrationEndpoint, redirectURI)
if err == nil {
// Persist so future token refresh can use the same client credentials.
_ = SaveClient(host, cl)
dcrLog.Log("registered client_id=%s", cl.ClientID)
if err := SaveClient(host, cl); err != nil {
dcrLog.Log("persisting client registration failed: %v", err)
}
return cl.ClientID, cl.ClientSecret, nil
}
// DCR failed — log and fall through to static client
dcrLog.Log("DCR failed: %v, trying static client", err)
fmt.Printf("Note: dynamic client registration failed (%v), trying static client\n", err)
} else {
dcrLog.Log("no registration endpoint, trying static client")
}

cfg, _ := config.LoadConfig()
if cfg != nil && cfg.OAuthClientID != "" {
dcrLog.Log("using static client_id=%s", cfg.OAuthClientID)
return cfg.OAuthClientID, cfg.OAuthClientSecret, nil
}

Expand Down Expand Up @@ -443,6 +498,7 @@ func fetchAuthServerMetadata(ctx context.Context, issuer string) (*authServerMet
}
// RFC 8414 path-aware: origin + /.well-known/oauth-authorization-server + path
u := parsed.Scheme + "://" + parsed.Host + "/.well-known/oauth-authorization-server" + parsed.Path
discoveryLog.Log("fetching RFC 8414 metadata: %s", u)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
if err != nil {
return nil, err
Expand Down Expand Up @@ -481,6 +537,7 @@ func extractEmailFromToken(ctx context.Context, provider *oidc.Provider, clientI
Email string `json:"email"`
}
if err := idToken.Claims(&claims); err == nil && claims.Email != "" {
emailLog.Log("email from OIDC ID token: %s", claims.Email)
return claims.Email
}
}
Expand All @@ -491,14 +548,21 @@ func extractEmailFromToken(ctx context.Context, provider *oidc.Provider, clientI
Email string `json:"email"`
}
if err := ui.Claims(&claims); err == nil && claims.Email != "" {
emailLog.Log("email from UserInfo endpoint: %s", claims.Email)
return claims.Email
}
}
}

// 2. Decode the access token as a JWT (no signature verification).
// Glean issues JWT access tokens that contain the user's email claim.
return EmailFromJWT(token.AccessToken)
email := EmailFromJWT(token.AccessToken)
if email != "" {
emailLog.Log("email from JWT access token: %s", email)
} else {
emailLog.Log("could not extract email from token")
}
return email
}

// EmailFromJWT decodes a JWT payload (without verification) and returns the
Expand Down
5 changes: 5 additions & 0 deletions internal/auth/discovery.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ type protectedResourceMetadata struct {
// baseURL is the Glean backend root (e.g. "https://myco-be.glean.com").
func fetchProtectedResource(ctx context.Context, baseURL string) (*protectedResourceMetadata, error) {
u := strings.TrimRight(baseURL, "/") + "/.well-known/oauth-protected-resource"
discoveryLog.Log("fetching protected resource: %s", u)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
if err != nil {
return nil, fmt.Errorf("building protected-resource request: %w", err)
Expand All @@ -45,6 +46,7 @@ func fetchProtectedResource(ctx context.Context, baseURL string) (*protectedReso
switch resp.StatusCode {
case http.StatusOK:
case http.StatusNotFound:
discoveryLog.Log("protected resource returned 404 — OAuth not supported")
return nil, &ErrOAuthNotSupported{URL: u}
default:
return nil, fmt.Errorf("protected resource metadata returned HTTP %d", resp.StatusCode)
Expand All @@ -57,6 +59,7 @@ func fetchProtectedResource(ctx context.Context, baseURL string) (*protectedReso
if len(meta.AuthorizationServers) == 0 {
return nil, fmt.Errorf("server returned OK but OAuth metadata is incomplete (no authorization_servers)")
}
discoveryLog.Log("found %d authorization server(s): %v", len(meta.AuthorizationServers), meta.AuthorizationServers)
return &meta, nil
}

Expand Down Expand Up @@ -88,6 +91,7 @@ func registerClient(ctx context.Context, registrationEndpoint, redirectURI strin
defer resp.Body.Close()

if resp.StatusCode != http.StatusCreated && resp.StatusCode != http.StatusOK {
dcrLog.Log("DCR returned HTTP %d", resp.StatusCode)
return nil, fmt.Errorf("DCR returned HTTP %d", resp.StatusCode)
}

Expand All @@ -101,5 +105,6 @@ func registerClient(ctx context.Context, registrationEndpoint, redirectURI strin
if result.ClientID == "" {
return nil, fmt.Errorf("DCR response missing client_id")
}
dcrLog.Log("DCR succeeded: client_id=%s", result.ClientID)
return &StoredClient{ClientID: result.ClientID, ClientSecret: result.ClientSecret}, nil
}
5 changes: 4 additions & 1 deletion internal/auth/domainlookup.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ func lookupBackendURL(ctx context.Context, email, endpoint string) (string, erro
if domain == "" {
return "", fmt.Errorf("invalid email address: %q", email)
}
hostLog.Log("domain lookup: domain=%s endpoint=%s", domain, endpoint)

body := map[string]any{
"email": email,
Expand Down Expand Up @@ -65,7 +66,9 @@ func lookupBackendURL(ctx context.Context, email, endpoint string) (string, erro
return "", fmt.Errorf("no Glean instance found for domain %q", domain)
}

return strings.TrimRight(result.SearchConfig.QueryURL, "/"), nil
backendURL := strings.TrimRight(result.SearchConfig.QueryURL, "/")
hostLog.Log("domain lookup resolved: %s", backendURL)
return backendURL, nil
}

func extractDomain(email string) string {
Expand Down
Loading
Loading