Skip to content
Open
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
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -98,14 +98,14 @@ glean search "engineering docs" --output ndjson | jq .title
### OAuth (recommended)

```bash snippet=readme/snippet-04.sh
glean auth login # opens browser, completes PKCE flow
glean auth login # browser PKCE flow, or device flow for SSO/Okta
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.
OAuth uses PKCE with Dynamic Client Registration when available. For SSO configurations where DCR is unavailable (e.g. Okta), `auth login` falls back to the Device Authorization Grant (RFC 8628) — you'll approve the login on a verification page instead. Tokens are stored securely in the system keyring and refreshed automatically.

For instances that don't support OAuth, `auth login` falls back to prompting for an API token.
For instances that don't support OAuth at all, `auth login` falls back to prompting for an API token.

### API Token (CI/CD)

Expand Down
160 changes: 110 additions & 50 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 @@ -24,51 +25,73 @@ import (
//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 {
host, err := resolveHost(ctx)
if err != nil {
return err
}

provider, endpoint, registrationEndpoint, err := discover(ctx, host)
disc, err := discover(ctx, host)
if err != nil {
fmt.Fprintf(os.Stderr, "\nOAuth discovery failed: %v\n", err)
return promptForAPIToken(host)
}

// 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.
// Try DCR / static client first (standard authorization code flow).
authCodeErr := tryAuthCodeLogin(ctx, host, disc)
if authCodeErr == nil {
return nil
}

// 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 {
fmt.Fprintf(os.Stderr, "Note: no OAuth client available, trying device flow…\n")
return deviceFlowLogin(ctx, host, disc)
}

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

// 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/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)

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 @@ -80,19 +103,15 @@ 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():
}
}()

token, err := oauth2cli.GetToken(ctx, oauth2cli.Config{
OAuth2Config: oauthCfg,
State: state,
// LocalServerBindAddress and LocalServerCallbackPath must match the
// redirect_uri registered via DCR exactly. oauth2cli constructs the
// redirect URL from LocalServerBindAddress (127.0.0.1:{port}) + path.
OAuth2Config: oauthCfg,
State: state,
LocalServerCallbackPath: "/callback",
LocalServerBindAddress: []string{fmt.Sprintf("127.0.0.1:%d", port)},
LocalServerReadyChan: readyChan,
Expand All @@ -104,15 +123,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 @@ -315,50 +341,80 @@ 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
meta, err := fetchProtectedResource(ctx, baseURL)
if err != nil {
return nil, oauth2.Endpoint{}, "", err
return nil, err
}

issuer := meta.AuthorizationServers[0]

// Try full OIDC discovery first (supports ID token, UserInfo).
provider, err := oidc.NewProvider(ctx, issuer)
if err == nil {
// 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"`
}
return provider, provider.Endpoint(), regEndpoint, nil
if err := provider.Claims(&providerClaims); err == nil {
res.RegistrationEndpoint = providerClaims.RegistrationEndpoint
res.DeviceAuthEndpoint = providerClaims.DeviceAuthorizationEndpoint
}

// 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
}

// 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 == "" {
return nil, oauth2.Endpoint{}, "", fmt.Errorf("OAuth metadata missing required endpoints for %s", issuer)
}

return nil, oauth2.Endpoint{
AuthURL: authMeta.AuthorizationEndpoint,
TokenURL: authMeta.TokenEndpoint,
}, authMeta.RegistrationEndpoint, nil
return nil, fmt.Errorf("OAuth metadata missing required endpoints for %s", issuer)
}

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 @@ -367,14 +423,14 @@ 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 != "" {
cl, err := registerClient(ctx, registrationEndpoint, redirectURI)
if err == nil {
// Persist so future token refresh can use the same client credentials.
_ = SaveClient(host, cl)
return cl.ClientID, cl.ClientSecret, nil
}
// DCR failed — log and fall through to static client
dcrErr = err
fmt.Printf("Note: dynamic client registration failed (%v), trying static client\n", err)
}

Expand All @@ -383,7 +439,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("dynamic client registration failed and no static client is configured: %w", 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 @@ -460,10 +519,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