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
22 changes: 15 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,8 @@ Search across your company's knowledge, chat with Glean Assistant, manage the fu
- [Quick Start](#quick-start)
- [Why Glean CLI?](#why-glean-cli)
- [Authentication](#authentication)
- [OAuth (recommended)](#oauth-recommended)
- [API Token (CI/CD)](#api-token-cicd)
- [Credential resolution order](#credential-resolution-order)
- [Interactive TUI](#interactive-tui)
- [Keyboard Shortcuts](#keyboard-shortcuts)
- [Slash Commands](#slash-commands)
Expand Down Expand Up @@ -95,28 +95,36 @@ glean search "engineering docs" --output ndjson | jq .title

## Authentication

### OAuth (recommended)

```bash snippet=readme/snippet-04.sh
glean auth login # opens browser, completes PKCE flow
glean auth login # interactive login (detects the best method automatically)
glean auth status # verify credentials, host, and token expiry
glean auth logout # remove all stored credentials
```

OAuth uses PKCE with Dynamic Client Registration — no client ID required. Tokens are stored securely in the system keyring and refreshed automatically.
`glean auth login` detects the right authentication method for your environment automatically:

| Method | When it's used | What happens |
| --- | --- | --- |
| **Browser login** | Default for most Glean instances | Opens your browser, you approve, done |
| **Device code login** | Organizations using an external IdP (e.g. Okta) | Prints a URL and code — open the URL, enter the code |
| **API token** | Instances without OAuth support | Prompts you to paste a token from Glean Admin |

For instances that don't support OAuth, `auth login` falls back to prompting for an API token.
You don't need to choose — `auth login` tries each method in order and uses the first one that works. Tokens are stored securely in the system keyring and refreshed automatically.

### API Token (CI/CD)

Set credentials via environment variables — no interactive login needed:
For non-interactive environments, set credentials via environment variables:

```bash snippet=readme/snippet-05.sh
export GLEAN_API_TOKEN=your-token
export GLEAN_HOST=your-company-be.glean.com
glean search "test"
```

API tokens are scoped to an individual user account. Generate one from **Glean Admin → Settings → API Tokens**.

### Credential resolution order

Credentials are resolved in this order: environment variables → system keyring → `~/.glean/config.json`.

## Interactive TUI
Expand Down
154 changes: 111 additions & 43 deletions internal/auth/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
_ "embed"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"net"
"net/http"
Expand All @@ -30,14 +31,24 @@ var (
dcrLog = debug.New("auth:dcr")
tokenLog = debug.New("auth:token")
emailLog = debug.New("auth:email")
deviceLog = debug.New("auth:device")
)

//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.
// errNoOAuthClient is returned by dcrOrStaticClient when neither DCR nor a
// static client is available. Login uses this to decide whether device flow
// is an appropriate fallback (as opposed to transient failures like network
// timeouts or the user closing their browser).
var errNoOAuthClient = errors.New("no OAuth client available")

// Login performs the full OAuth 2.0 login flow for the configured Glean host.
//
// Strategy (in order):
// 1. Authorization Code + PKCE via DCR or static client
// 2. Device Authorization Grant (RFC 8628) using the Glean-advertised client ID
// 3. Inline API token prompt when OAuth is not available at all
func Login(ctx context.Context) error {
loginLog.Log("starting login flow")

Expand All @@ -47,45 +58,61 @@ func Login(ctx context.Context) error {
}
loginLog.Log("host resolved: %s", host)

provider, endpoint, registrationEndpoint, err := discover(ctx, host)
disc, 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)
loginLog.Log("OAuth discovery succeeded: auth=%s token=%s registration=%s", disc.Endpoint.AuthURL, disc.Endpoint.TokenURL, disc.RegistrationEndpoint)

// Try DCR / static client first (standard authorization code flow).
loginLog.Log("attempting authorization code + PKCE flow")
authCodeErr := tryAuthCodeLogin(ctx, host, disc)
if authCodeErr == nil {
return nil
}
loginLog.Log("auth code flow failed: %v", authCodeErr)

// Only fall back to device flow when the auth code flow failed because no
// OAuth client could be obtained (DCR unsupported + no static client).
// Transient failures (network, user closing browser, port conflicts) should
// not silently switch to a different grant type.
canDeviceFlow := disc.DeviceFlowClientID != "" && disc.DeviceAuthEndpoint != ""
if errors.Is(authCodeErr, errNoOAuthClient) && canDeviceFlow {
loginLog.Log("falling back to device authorization grant (client_id=%s)", disc.DeviceFlowClientID)
fmt.Fprintf(os.Stderr, "\nYour SSO provider requires device-based login.\n")
return deviceFlowLogin(ctx, host, disc)
}

return fmt.Errorf("authentication failed: %w", authCodeErr)
}

// Find a free port for the local callback server.
// This must happen before DCR so we register the exact redirect URI
// that oauth2cli will use — a mismatch causes a silent hang.
// tryAuthCodeLogin attempts the Authorization Code + PKCE flow via DCR or static client.
func tryAuthCodeLogin(ctx context.Context, host string, disc *discoveryResult) error {
port, err := findFreePort()
if err != nil {
return fmt.Errorf("finding callback port: %w", err)
}
redirectURI := fmt.Sprintf("http://127.0.0.1:%d/glean-cli-callback", port)

// Always do fresh DCR per login — the redirect URI (port) changes each time.
clientID, clientSecret, err := dcrOrStaticClient(ctx, host, registrationEndpoint, redirectURI)
clientID, clientSecret, err := dcrOrStaticClient(ctx, host, disc.RegistrationEndpoint, redirectURI)
if err != nil {
return fmt.Errorf("resolving OAuth client: %w", err)
return err
}

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

oauthCfg := oauth2.Config{
ClientID: clientID,
ClientSecret: clientSecret,
Endpoint: endpoint,
Endpoint: disc.Endpoint,
Scopes: scopes,
RedirectURL: redirectURI,
}

// oauth2cli v1.15.1 does not open the browser itself — the caller must do it.
// LocalServerReadyChan receives the local server URL once the callback server
// is ready. We open the browser to that URL (which the local server redirects
// to the real OAuth page), and also print the direct auth URL as a fallback.
state := oauth2.GenerateVerifier()[:20]
authURL := oauthCfg.AuthCodeURL(state, oauth2.S256ChallengeOption(verifier))

Expand All @@ -97,7 +124,6 @@ func Login(ctx context.Context) error {
fmt.Printf("If your browser doesn't open, visit:\n %s\n\n", authURL)
fmt.Printf("Waiting for you to complete login in the browser…\n")
if err := browser.OpenURL(localURL); err != nil {
// Browser failed to open — the printed URL is the fallback.
fmt.Printf("(Could not open browser automatically: %v)\n", err)
}
case <-ctx.Done():
Expand All @@ -121,15 +147,22 @@ func Login(ctx context.Context) error {
return fmt.Errorf("OAuth login failed: %w", err)
}

email := extractEmailFromToken(ctx, provider, clientID, token)
return saveAndPrintToken(ctx, host, disc, oauthCfg.ClientID, token)
}

// saveAndPrintToken persists the OAuth token and client, then prints a success message.
func saveAndPrintToken(ctx context.Context, host string, disc *discoveryResult, clientID string, token *oauth2.Token) error {
_ = SaveClient(host, &StoredClient{ClientID: clientID})

email := extractEmailFromToken(ctx, disc.Provider, clientID, token)

stored := &StoredTokens{
AccessToken: token.AccessToken,
RefreshToken: token.RefreshToken,
Expiry: token.Expiry,
Email: email,
TokenType: token.TokenType,
TokenEndpoint: oauthCfg.Endpoint.TokenURL, // enables future token refresh
TokenEndpoint: disc.Endpoint.TokenURL,
}
if err := persistLoginState(host, stored); err != nil {
return err
Expand Down Expand Up @@ -356,23 +389,29 @@ func resolveHost(ctx context.Context) (string, error) {
return host, nil
}

// discoveryResult holds all OAuth metadata discovered for a Glean backend.
type discoveryResult struct {
Provider *oidc.Provider
Endpoint oauth2.Endpoint
RegistrationEndpoint string
DeviceFlowClientID string
DeviceAuthEndpoint string
}

// discover resolves the OAuth2 endpoint and registration endpoint for the Glean backend.
//
// Strategy:
// 1. Fetch RFC 9728 protected resource metadata → get authorization server URL
// 2. Try OIDC discovery (oidc.NewProvider) for full OIDC support
// 3. Fall back to RFC 8414 auth server metadata when OIDC is unavailable
// (Glean uses RFC 8414 but does not serve /.well-known/openid-configuration)
//
// Returns (provider, oauth2Endpoint, registrationEndpoint, error).
// provider is nil when only RFC 8414 discovery succeeded.
func discover(ctx context.Context, host string) (*oidc.Provider, oauth2.Endpoint, string, error) {
func discover(ctx context.Context, host string) (*discoveryResult, 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
return nil, err
}

issuer := meta.AuthorizationServers[0]
Expand All @@ -383,31 +422,55 @@ func discover(ctx context.Context, host string) (*oidc.Provider, oauth2.Endpoint
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 := ""
if authMeta != nil {
regEndpoint = authMeta.RegistrationEndpoint
res := &discoveryResult{Provider: provider, Endpoint: provider.Endpoint()}
res.DeviceFlowClientID = meta.GleanDeviceFlowClientID

// Extract device_authorization_endpoint from OIDC provider claims
// (RFC 8414 metadata may omit it even when OIDC metadata includes it).
var providerClaims struct {
RegistrationEndpoint string `json:"registration_endpoint"`
DeviceAuthorizationEndpoint string `json:"device_authorization_endpoint"`
}
if err := provider.Claims(&providerClaims); err == nil {
res.RegistrationEndpoint = providerClaims.RegistrationEndpoint
res.DeviceAuthEndpoint = providerClaims.DeviceAuthorizationEndpoint
}
return provider, provider.Endpoint(), regEndpoint, nil

// Supplement from RFC 8414 if OIDC claims were incomplete.
if res.RegistrationEndpoint == "" || res.DeviceAuthEndpoint == "" {
if authMeta, err := fetchAuthServerMetadata(ctx, issuer); err == nil {
if res.RegistrationEndpoint == "" {
res.RegistrationEndpoint = authMeta.RegistrationEndpoint
}
if res.DeviceAuthEndpoint == "" {
res.DeviceAuthEndpoint = authMeta.DeviceAuthorizationEndpoint
}
}
}
return res, 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)
return nil, 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)
return nil, 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,
TokenURL: authMeta.TokenEndpoint,
}, authMeta.RegistrationEndpoint, nil
return &discoveryResult{
Endpoint: oauth2.Endpoint{
AuthURL: authMeta.AuthorizationEndpoint,
TokenURL: authMeta.TokenEndpoint,
},
RegistrationEndpoint: authMeta.RegistrationEndpoint,
DeviceFlowClientID: meta.GleanDeviceFlowClientID,
DeviceAuthEndpoint: authMeta.DeviceAuthorizationEndpoint,
}, nil
}

// dcrOrStaticClient resolves the OAuth client_id/secret for a login session.
Expand All @@ -416,6 +479,7 @@ func discover(ctx context.Context, host string) (*oidc.Provider, oauth2.Endpoint
// credentials can be reused for token refresh later.
// 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) {
var dcrErr error
if registrationEndpoint != "" {
dcrLog.Log("registering client at %s with redirect %s", registrationEndpoint, redirectURI)
cl, err := registerClient(ctx, registrationEndpoint, redirectURI)
Expand All @@ -426,7 +490,7 @@ func dcrOrStaticClient(ctx context.Context, host, registrationEndpoint, redirect
}
return cl.ClientID, cl.ClientSecret, nil
}
// DCR failed — log and fall through to static client
dcrErr = err
dcrLog.Log("DCR failed: %v, trying static client", err)
fmt.Printf("Note: dynamic client registration failed (%v), trying static client\n", err)
} else {
Expand All @@ -439,7 +503,10 @@ func dcrOrStaticClient(ctx context.Context, host, registrationEndpoint, redirect
return cfg.OAuthClientID, cfg.OAuthClientSecret, nil
}

return "", "", fmt.Errorf("no OAuth client available — dynamic client registration failed and no static client is configured")
if dcrErr != nil {
return "", "", fmt.Errorf("%w: dynamic client registration failed (%v) and no static client is configured", errNoOAuthClient, dcrErr)
}
return "", "", fmt.Errorf("%w: no registration endpoint and no static client configured", errNoOAuthClient)
}

// resolveScopes returns the appropriate OAuth scopes for the given provider.
Expand Down Expand Up @@ -517,10 +584,11 @@ func fetchAuthServerMetadata(ctx context.Context, issuer string) (*authServerMet
}

type authServerMeta struct {
Issuer string `json:"issuer"`
AuthorizationEndpoint string `json:"authorization_endpoint"`
TokenEndpoint string `json:"token_endpoint"`
RegistrationEndpoint string `json:"registration_endpoint,omitempty"`
Issuer string `json:"issuer"`
AuthorizationEndpoint string `json:"authorization_endpoint"`
TokenEndpoint string `json:"token_endpoint"`
RegistrationEndpoint string `json:"registration_endpoint,omitempty"`
DeviceAuthorizationEndpoint string `json:"device_authorization_endpoint,omitempty"`
}

// extractEmailFromToken pulls the user email from the token.
Expand Down
Loading
Loading