From befaf318d320c945aff370dcf4428fa281615577 Mon Sep 17 00:00:00 2001 From: Austin DeNoble Date: Mon, 13 Apr 2026 15:06:39 -0400 Subject: [PATCH 01/15] Add SSO connection routing for org-scoped login and pc target re-auth MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When authenticating into an organization that has SSO enforced, the CLI now passes the Auth0 connection name as a connection= parameter to the authorization endpoint, routing the browser directly to the org's identity provider rather than the generic login page. Both pc login and pc auth login accept a new --org flag to scope the login to a specific organization. pc target already passes the org ID implicitly. In all cases the SSO connection is resolved by calling the dashboard organizations API with the user's existing token; the lookup is best-effort and non-fatal — if it fails the flow falls back to the standard login page transparently. --- internal/pkg/cli/command/auth/login.go | 11 ++- internal/pkg/cli/command/login/login.go | 11 ++- internal/pkg/utils/login/login.go | 32 ++++++- internal/pkg/utils/login/sso.go | 91 +++++++++++++++++++ internal/pkg/utils/login/sso_test.go | 115 ++++++++++++++++++++++++ internal/pkg/utils/oauth/auth.go | 5 +- internal/pkg/utils/oauth/auth_test.go | 82 ++++++++++++++++- 7 files changed, 337 insertions(+), 10 deletions(-) create mode 100644 internal/pkg/utils/login/sso.go create mode 100644 internal/pkg/utils/login/sso_test.go diff --git a/internal/pkg/cli/command/auth/login.go b/internal/pkg/cli/command/auth/login.go index 1b264f3..0916590 100644 --- a/internal/pkg/cli/command/auth/login.go +++ b/internal/pkg/cli/command/auth/login.go @@ -49,6 +49,7 @@ var ( func NewLoginCmd() *cobra.Command { var jsonOutput bool + var orgId string cmd := &cobra.Command{ Use: "login", @@ -58,6 +59,9 @@ func NewLoginCmd() *cobra.Command { # Interactive login (opens a browser) pc auth login + # Login scoped to a specific organization (enables SSO routing) + pc auth login --org "ORG_ID" + # Agentic login — first call returns a pending URL pc auth login --json @@ -66,11 +70,16 @@ func NewLoginCmd() *cobra.Command { `), GroupID: help.GROUP_AUTH.ID, Run: func(cmd *cobra.Command, args []string) { - login.Run(cmd.Context(), login.Options{Json: jsonOutput}) + opts := login.Options{Json: jsonOutput} + if cmd.Flags().Changed("org") { + opts.OrgId = &orgId + } + login.Run(cmd.Context(), opts) }, } cmd.Flags().BoolVarP(&jsonOutput, "json", "j", false, "emit JSON output") + cmd.Flags().StringVar(&orgId, "org", "", "Organization ID to authenticate into (enables SSO routing for organizations with SSO enforced)") return cmd } diff --git a/internal/pkg/cli/command/login/login.go b/internal/pkg/cli/command/login/login.go index 40a6802..efcc567 100644 --- a/internal/pkg/cli/command/login/login.go +++ b/internal/pkg/cli/command/login/login.go @@ -41,6 +41,7 @@ var ( func NewLoginCmd() *cobra.Command { var jsonOutput bool + var orgId string cmd := &cobra.Command{ Use: "login", @@ -50,6 +51,9 @@ func NewLoginCmd() *cobra.Command { # Interactive login (opens a browser) pc login + # Login scoped to a specific organization (enables SSO routing) + pc login --org "ORG_ID" + # Agentic login — first call returns a pending URL pc login --json @@ -58,11 +62,16 @@ func NewLoginCmd() *cobra.Command { `), GroupID: help.GROUP_AUTH.ID, Run: func(cmd *cobra.Command, args []string) { - login.Run(cmd.Context(), login.Options{Json: jsonOutput}) + opts := login.Options{Json: jsonOutput} + if cmd.Flags().Changed("org") { + opts.OrgId = &orgId + } + login.Run(cmd.Context(), opts) }, } cmd.Flags().BoolVarP(&jsonOutput, "json", "j", false, "emit JSON output") + cmd.Flags().StringVar(&orgId, "org", "", "Organization ID to authenticate into (enables SSO routing for organizations with SSO enforced)") return cmd } diff --git a/internal/pkg/utils/login/login.go b/internal/pkg/utils/login/login.go index 4237428..79a17f0 100644 --- a/internal/pkg/utils/login/login.go +++ b/internal/pkg/utils/login/login.go @@ -46,6 +46,10 @@ type Options struct { // RunPostAuthSetup is not called in Wait mode; the caller is responsible // for any post-auth state setup and output. Wait bool + // OrgId pins the login flow to a specific organization. When set, the SSO + // connection for that org is looked up and passed to Auth0, routing the + // browser directly to the org's identity provider if SSO is enforced. + OrgId *string } func Run(ctx context.Context, opts Options) { @@ -99,7 +103,7 @@ func Run(ctx context.Context, opts Options) { return } - err = GetAndSetAccessToken(ctx, nil, opts) + err = GetAndSetAccessToken(ctx, opts.OrgId, opts) if err != nil { msg.FailMsg("Error acquiring access token while logging in: %s", err) exit.Error(err, "Error acquiring access token while logging in") @@ -238,7 +242,18 @@ func getAndSetAccessTokenJSON(ctx context.Context, orgId *string, wait bool, ses return fmt.Errorf("error creating new auth verifier and challenge: %w", err) } - authURL, err := a.GetAuthURL(ctx, csrfState, challenge, orgId) + var ssoConnection *string + if orgId != nil && *orgId != "" { + conn, err := FetchSSOConnection(ctx, *orgId) + if err != nil { + log.Debug().Err(err).Msg("SSO connection lookup failed, proceeding without connection param") + } + if conn != "" { + ssoConnection = &conn + } + } + + authURL, err := a.GetAuthURL(ctx, csrfState, challenge, orgId, ssoConnection) if err != nil { return fmt.Errorf("error getting auth URL: %w", err) } @@ -398,7 +413,18 @@ func getAndSetAccessTokenInteractive(ctx context.Context, orgId *string) error { return fmt.Errorf("error creating new auth verifier and challenge: %w", err) } - authURL, err := a.GetAuthURL(ctx, csrfState, challenge, orgId) + var ssoConnection *string + if orgId != nil && *orgId != "" { + conn, err := FetchSSOConnection(ctx, *orgId) + if err != nil { + log.Debug().Err(err).Msg("SSO connection lookup failed, proceeding without connection param") + } + if conn != "" { + ssoConnection = &conn + } + } + + authURL, err := a.GetAuthURL(ctx, csrfState, challenge, orgId, ssoConnection) if err != nil { return fmt.Errorf("error getting auth URL: %w", err) } diff --git a/internal/pkg/utils/login/sso.go b/internal/pkg/utils/login/sso.go new file mode 100644 index 0000000..358f4f0 --- /dev/null +++ b/internal/pkg/utils/login/sso.go @@ -0,0 +1,91 @@ +package login + +import ( + "context" + "encoding/json" + "io" + "net/http" + + "github.com/pinecone-io/cli/internal/pkg/utils/configuration/config" + "github.com/pinecone-io/cli/internal/pkg/utils/environment" + "github.com/pinecone-io/cli/internal/pkg/utils/log" + "github.com/pinecone-io/cli/internal/pkg/utils/oauth" +) + +// dashboardOrg is the subset of the dashboard API org response needed for SSO lookup. +type dashboardOrg struct { + Id string `json:"id"` + SSOConnectionName string `json:"sso_connection_name"` + EnforceSSO bool `json:"enforce_sso_authentication"` +} + +type dashboardOrgsResponse struct { + NewOrgs []dashboardOrg `json:"newOrgs"` +} + +// FetchSSOConnection calls the private dashboard API to retrieve the Auth0 +// connection name for the given orgId. It returns ("", nil) when the org has +// no SSO configured, enforce_sso_authentication is false, or any error occurs. +// Errors are non-fatal: the caller should proceed with a normal login URL. +func FetchSSOConnection(ctx context.Context, orgId string) (string, error) { + token, err := oauth.Token(ctx) + if err != nil || token == nil || token.AccessToken == "" { + log.Debug().Str("orgId", orgId).Msg("SSO lookup skipped: no valid token available") + return "", nil + } + + envConfig, err := environment.GetEnvConfig(config.Environment.Get()) + if err != nil { + return "", nil + } + + return fetchSSOConnectionFromURL(ctx, orgId, token.AccessToken, http.DefaultClient, envConfig.DashboardUrl) +} + +// fetchSSOConnectionFromURL is the testable core: it takes an explicit HTTP +// client and dashboard base URL so tests can inject a local httptest.Server. +func fetchSSOConnectionFromURL(ctx context.Context, orgId string, accessToken string, client *http.Client, dashboardURL string) (string, error) { + url := dashboardURL + "/v2/dashboard/organizations" + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return "", nil + } + req.Header.Set("Authorization", "Bearer "+accessToken) + + resp, err := client.Do(req) + if err != nil { + log.Debug().Err(err).Str("orgId", orgId).Msg("SSO lookup: dashboard API request failed") + return "", nil + } + defer resp.Body.Close() + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + log.Debug().Int("status", resp.StatusCode).Str("orgId", orgId).Msg("SSO lookup: dashboard API returned non-2xx") + return "", nil + } + + body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) + if err != nil { + log.Debug().Err(err).Str("orgId", orgId).Msg("SSO lookup: failed to read dashboard API response") + return "", nil + } + + var orgsResp dashboardOrgsResponse + if err := json.Unmarshal(body, &orgsResp); err != nil { + log.Debug().Err(err).Str("orgId", orgId).Msg("SSO lookup: failed to decode dashboard API response") + return "", nil + } + + for _, org := range orgsResp.NewOrgs { + if org.Id == orgId { + if org.EnforceSSO && org.SSOConnectionName != "" { + log.Debug().Str("orgId", orgId).Str("connection", org.SSOConnectionName).Msg("SSO lookup: found connection") + return org.SSOConnectionName, nil + } + return "", nil + } + } + + log.Debug().Str("orgId", orgId).Msg("SSO lookup: org not found in dashboard response") + return "", nil +} diff --git a/internal/pkg/utils/login/sso_test.go b/internal/pkg/utils/login/sso_test.go new file mode 100644 index 0000000..77dbe89 --- /dev/null +++ b/internal/pkg/utils/login/sso_test.go @@ -0,0 +1,115 @@ +package login + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" +) + +// newDashboardServer starts an httptest.Server that returns the given org list. +// Pass a non-zero statusCode to simulate an error response. +func newDashboardServer(t *testing.T, orgs []dashboardOrg, statusCode int) *httptest.Server { + t.Helper() + if statusCode == 0 { + statusCode = http.StatusOK + } + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if statusCode != http.StatusOK { + http.Error(w, "error", statusCode) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(dashboardOrgsResponse{NewOrgs: orgs}) + })) +} + +func TestFetchSSOConnection_EnforcedWithConnection(t *testing.T) { + server := newDashboardServer(t, []dashboardOrg{ + {Id: "org-1", SSOConnectionName: "alby-saml", EnforceSSO: true}, + }, 0) + defer server.Close() + + conn, err := fetchSSOConnectionFromURL(context.Background(), "org-1", "fake-token", server.Client(), server.URL) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if conn != "alby-saml" { + t.Errorf("expected %q, got %q", "alby-saml", conn) + } +} + +func TestFetchSSOConnection_NotEnforced(t *testing.T) { + server := newDashboardServer(t, []dashboardOrg{ + {Id: "org-1", SSOConnectionName: "alby-saml", EnforceSSO: false}, + }, 0) + defer server.Close() + + conn, err := fetchSSOConnectionFromURL(context.Background(), "org-1", "fake-token", server.Client(), server.URL) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if conn != "" { + t.Errorf("expected empty connection when SSO not enforced, got %q", conn) + } +} + +func TestFetchSSOConnection_OrgNotFound(t *testing.T) { + server := newDashboardServer(t, []dashboardOrg{ + {Id: "org-other", SSOConnectionName: "other-saml", EnforceSSO: true}, + }, 0) + defer server.Close() + + conn, err := fetchSSOConnectionFromURL(context.Background(), "org-1", "fake-token", server.Client(), server.URL) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if conn != "" { + t.Errorf("expected empty connection when org not found, got %q", conn) + } +} + +func TestFetchSSOConnection_NonOKStatus(t *testing.T) { + server := newDashboardServer(t, nil, http.StatusUnauthorized) + defer server.Close() + + conn, err := fetchSSOConnectionFromURL(context.Background(), "org-1", "fake-token", server.Client(), server.URL) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if conn != "" { + t.Errorf("expected empty connection on non-2xx response, got %q", conn) + } +} + +func TestFetchSSOConnection_EmptyConnectionName(t *testing.T) { + server := newDashboardServer(t, []dashboardOrg{ + {Id: "org-1", SSOConnectionName: "", EnforceSSO: true}, + }, 0) + defer server.Close() + + conn, err := fetchSSOConnectionFromURL(context.Background(), "org-1", "fake-token", server.Client(), server.URL) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if conn != "" { + t.Errorf("expected empty connection when name is empty, got %q", conn) + } +} + +func TestFetchSSOConnection_MultipleOrgs(t *testing.T) { + server := newDashboardServer(t, []dashboardOrg{ + {Id: "org-1", SSOConnectionName: "org1-saml", EnforceSSO: true}, + {Id: "org-2", SSOConnectionName: "org2-saml", EnforceSSO: true}, + }, 0) + defer server.Close() + + conn, err := fetchSSOConnectionFromURL(context.Background(), "org-2", "fake-token", server.Client(), server.URL) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if conn != "org2-saml" { + t.Errorf("expected %q, got %q", "org2-saml", conn) + } +} diff --git a/internal/pkg/utils/oauth/auth.go b/internal/pkg/utils/oauth/auth.go index 0bb0ebb..fea55a6 100644 --- a/internal/pkg/utils/oauth/auth.go +++ b/internal/pkg/utils/oauth/auth.go @@ -15,7 +15,7 @@ const ( SourceTag = "pinecone_cli" ) -func (a *Auth) GetAuthURL(ctx context.Context, csrfState string, codeChallenge string, orgId *string) (string, error) { +func (a *Auth) GetAuthURL(ctx context.Context, csrfState string, codeChallenge string, orgId *string, ssoConnection *string) (string, error) { conf, err := newOauth2Config() if err != nil { return "", err @@ -34,6 +34,9 @@ func (a *Auth) GetAuthURL(ctx context.Context, csrfState string, codeChallenge s if orgId != nil && *orgId != "" { opts = append(opts, oauth2.SetAuthURLParam("orgId", *orgId)) } + if ssoConnection != nil && *ssoConnection != "" { + opts = append(opts, oauth2.SetAuthURLParam("connection", *ssoConnection)) + } return conf.AuthCodeURL(csrfState, opts...), nil } diff --git a/internal/pkg/utils/oauth/auth_test.go b/internal/pkg/utils/oauth/auth_test.go index e275cff..d5a40a8 100644 --- a/internal/pkg/utils/oauth/auth_test.go +++ b/internal/pkg/utils/oauth/auth_test.go @@ -21,7 +21,7 @@ func TestGetAuthURL_ContainsSourceTag(t *testing.T) { t.Fatalf("failed to create verifier/challenge: %v", err) } - rawURL, err := a.GetAuthURL(ctx, "test-csrf-state", challenge, nil) + rawURL, err := a.GetAuthURL(ctx, "test-csrf-state", challenge, nil, nil) if err != nil { t.Fatalf("GetAuthURL returned error: %v", err) } @@ -48,7 +48,7 @@ func TestGetAuthURL_RequiredParams(t *testing.T) { } csrfState := "test-state-123" - rawURL, err := a.GetAuthURL(ctx, csrfState, challenge, nil) + rawURL, err := a.GetAuthURL(ctx, csrfState, challenge, nil, nil) if err != nil { t.Fatalf("GetAuthURL returned error: %v", err) } @@ -84,7 +84,7 @@ func TestGetAuthURL_WithOrgId(t *testing.T) { } orgId := "test-org-456" - rawURL, err := a.GetAuthURL(ctx, "state", challenge, &orgId) + rawURL, err := a.GetAuthURL(ctx, "state", challenge, &orgId, nil) if err != nil { t.Fatalf("GetAuthURL returned error: %v", err) } @@ -109,7 +109,7 @@ func TestGetAuthURL_WithEmptyOrgId(t *testing.T) { } emptyOrgId := "" - rawURL, err := a.GetAuthURL(ctx, "state", challenge, &emptyOrgId) + rawURL, err := a.GetAuthURL(ctx, "state", challenge, &emptyOrgId, nil) if err != nil { t.Fatalf("GetAuthURL returned error: %v", err) } @@ -123,3 +123,77 @@ func TestGetAuthURL_WithEmptyOrgId(t *testing.T) { t.Errorf("expected orgId to be absent for empty string, got %q", got) } } + +func TestGetAuthURL_WithSSOConnection(t *testing.T) { + a := &Auth{} + ctx := context.Background() + + _, challenge, err := a.CreateNewVerifierAndChallenge() + if err != nil { + t.Fatalf("failed to create verifier/challenge: %v", err) + } + + connection := "alby-saml" + rawURL, err := a.GetAuthURL(ctx, "state", challenge, nil, &connection) + if err != nil { + t.Fatalf("GetAuthURL returned error: %v", err) + } + + parsed, err := url.Parse(rawURL) + if err != nil { + t.Fatalf("failed to parse auth URL: %v", err) + } + + if got := parsed.Query().Get("connection"); got != connection { + t.Errorf("expected connection=%q, got %q", connection, got) + } +} + +func TestGetAuthURL_WithNilSSOConnection(t *testing.T) { + a := &Auth{} + ctx := context.Background() + + _, challenge, err := a.CreateNewVerifierAndChallenge() + if err != nil { + t.Fatalf("failed to create verifier/challenge: %v", err) + } + + rawURL, err := a.GetAuthURL(ctx, "state", challenge, nil, nil) + if err != nil { + t.Fatalf("GetAuthURL returned error: %v", err) + } + + parsed, err := url.Parse(rawURL) + if err != nil { + t.Fatalf("failed to parse auth URL: %v", err) + } + + if got := parsed.Query().Get("connection"); got != "" { + t.Errorf("expected connection param to be absent, got %q", got) + } +} + +func TestGetAuthURL_WithEmptySSOConnection(t *testing.T) { + a := &Auth{} + ctx := context.Background() + + _, challenge, err := a.CreateNewVerifierAndChallenge() + if err != nil { + t.Fatalf("failed to create verifier/challenge: %v", err) + } + + emptyConnection := "" + rawURL, err := a.GetAuthURL(ctx, "state", challenge, nil, &emptyConnection) + if err != nil { + t.Fatalf("GetAuthURL returned error: %v", err) + } + + parsed, err := url.Parse(rawURL) + if err != nil { + t.Fatalf("failed to parse auth URL: %v", err) + } + + if got := parsed.Query().Get("connection"); got != "" { + t.Errorf("expected connection param to be absent for empty string, got %q", got) + } +} From b539a5f6e4b539067eed29e4864b908ace7cc8ec Mon Sep 17 00:00:00 2001 From: Austin DeNoble Date: Tue, 14 Apr 2026 03:39:39 -0400 Subject: [PATCH 02/15] call login.FetchSSOConnection before oauth.Logout() for both login and targeting flows --- internal/pkg/cli/command/target/target.go | 22 +++++- internal/pkg/utils/login/login.go | 92 ++++++++++++----------- 2 files changed, 68 insertions(+), 46 deletions(-) diff --git a/internal/pkg/cli/command/target/target.go b/internal/pkg/cli/command/target/target.go index 6f91402..35f6283 100644 --- a/internal/pkg/cli/command/target/target.go +++ b/internal/pkg/cli/command/target/target.go @@ -199,8 +199,17 @@ func NewTargetCmd() *cobra.Command { // If the org chosen differs from the current orgId in the token, we need to login again if currentTokenOrgId != "" && currentTokenOrgId != targetOrg.Id { + // Fetch SSO connection while the current token is still valid, + // before logout clears it. + var ssoConn *string + conn, connErr := login.FetchSSOConnection(ctx, targetOrg.Id) + if connErr != nil { + log.Debug().Err(connErr).Msg("SSO connection lookup failed, proceeding without connection param") + } else if conn != "" { + ssoConn = &conn + } oauth.Logout() - err = login.GetAndSetAccessToken(ctx, &targetOrg.Id, login.Options{Json: options.json, Wait: true}) + err = login.GetAndSetAccessToken(ctx, &targetOrg.Id, login.Options{Json: options.json, Wait: true, SSOConnection: ssoConn}) if err != nil { msg.FailJSON(options.json, "Failed to get access token: %s", err) exit.Error(err, "Error getting access token") @@ -245,8 +254,17 @@ func NewTargetCmd() *cobra.Command { // If the org chosen differs from the current orgId in the token, we need to login again if currentTokenOrgId != org.Id { + // Fetch SSO connection while the current token is still valid, + // before logout clears it. + var ssoConn *string + conn, connErr := login.FetchSSOConnection(ctx, org.Id) + if connErr != nil { + log.Debug().Err(connErr).Msg("SSO connection lookup failed, proceeding without connection param") + } else if conn != "" { + ssoConn = &conn + } oauth.Logout() - err = login.GetAndSetAccessToken(ctx, &org.Id, login.Options{Json: options.json, Wait: true}) + err = login.GetAndSetAccessToken(ctx, &org.Id, login.Options{Json: options.json, Wait: true, SSOConnection: ssoConn}) if err != nil { msg.FailJSON(options.json, "Failed to get access token: %s", err) exit.Error(err, "Error getting access token") diff --git a/internal/pkg/utils/login/login.go b/internal/pkg/utils/login/login.go index 79a17f0..a52adcd 100644 --- a/internal/pkg/utils/login/login.go +++ b/internal/pkg/utils/login/login.go @@ -46,10 +46,13 @@ type Options struct { // RunPostAuthSetup is not called in Wait mode; the caller is responsible // for any post-auth state setup and output. Wait bool - // OrgId pins the login flow to a specific organization. When set, the SSO - // connection for that org is looked up and passed to Auth0, routing the - // browser directly to the org's identity provider if SSO is enforced. + // OrgId pins the login flow to a specific organization. OrgId *string + // SSOConnection is the Auth0 connection name to pass as `connection=` in the + // authorization URL, routing the browser directly to the org's IdP. + // Callers that hold a valid token before clearing credentials (e.g. pc target) + // should resolve this with FetchSSOConnection before logout, then pass it here. + SSOConnection *string } func Run(ctx context.Context, opts Options) { @@ -66,7 +69,7 @@ func Run(ctx context.Context, opts Options) { exit.Error(err, "Error checking for existing auth session") } if sess != nil { - if err := getAndSetAccessTokenJSON(ctx, nil, false, sess, result); err != nil { + if err := getAndSetAccessTokenJSON(ctx, nil, false, nil, sess, result); err != nil { msg.FailMsg("Error acquiring access token while logging in: %s", err) exit.Error(err, "Error acquiring access token while logging in") } @@ -84,23 +87,46 @@ func Run(ctx context.Context, opts Options) { } if !expired && token != nil && token.AccessToken != "" { - if opts.Json { - claims, err := oauth.ParseClaimsUnverified(token) - if err == nil { - fmt.Fprintln(os.Stdout, text.IndentJSON(struct { - Status string `json:"status"` - Email string `json:"email"` - OrgId string `json:"org_id"` - }{Status: "already_authenticated", Email: claims.Email, OrgId: claims.OrgId})) - } else { - fmt.Fprintln(os.Stdout, text.IndentJSON(struct { - Status string `json:"status"` - }{Status: "already_authenticated"})) + // If --org targets a different organization, re-authenticate now while + // the token is still valid so we can look up the SSO connection before + // clearing credentials. + differentOrg := false + if opts.OrgId != nil && *opts.OrgId != "" { + if claims, claimsErr := oauth.ParseClaimsUnverified(token); claimsErr == nil { + differentOrg = claims.OrgId != *opts.OrgId + } + } + + if differentOrg { + conn, lookupErr := FetchSSOConnection(ctx, *opts.OrgId) + if lookupErr != nil { + log.Debug().Err(lookupErr).Msg("SSO connection lookup failed, proceeding without connection param") + } + if conn != "" { + opts.SSOConnection = &conn } + oauth.Logout() + // Fall through to GetAndSetAccessToken. } else { - msg.WarnMsg("You are already logged in. Please log out first using %s.", style.Code("pc auth logout")) + // Same org (or no --org flag) — show "already logged in". + if opts.Json { + claims, err := oauth.ParseClaimsUnverified(token) + if err == nil { + fmt.Fprintln(os.Stdout, text.IndentJSON(struct { + Status string `json:"status"` + Email string `json:"email"` + OrgId string `json:"org_id"` + }{Status: "already_authenticated", Email: claims.Email, OrgId: claims.OrgId})) + } else { + fmt.Fprintln(os.Stdout, text.IndentJSON(struct { + Status string `json:"status"` + }{Status: "already_authenticated"})) + } + } else { + msg.WarnMsg("You are already logged in. Please log out first using %s.", style.Code("pc auth logout")) + } + return } - return } err = GetAndSetAccessToken(ctx, opts.OrgId, opts) @@ -192,9 +218,9 @@ func GetAndSetAccessToken(ctx context.Context, orgId *string, opts Options) erro // a terminal (agentic context), always use the JSON/daemon path. opts.Json = opts.Json || !term.IsTerminal(int(os.Stdout.Fd())) if opts.Json { - return getAndSetAccessTokenJSON(ctx, orgId, opts.Wait, nil, nil) + return getAndSetAccessTokenJSON(ctx, orgId, opts.Wait, opts.SSOConnection, nil, nil) } - return getAndSetAccessTokenInteractive(ctx, orgId) + return getAndSetAccessTokenInteractive(ctx, orgId, opts.SSOConnection) } // getAndSetAccessTokenJSON is the agentic path: daemon-backed, non-blocking on stdin. @@ -207,7 +233,7 @@ func GetAndSetAccessToken(ctx context.Context, orgId *string, opts Options) erro // When wait is true (for callers like pc target that need a token on return): spawns // daemon, blocks until auth completes, and returns with the token stored. RunPostAuthSetup // is not called; the caller owns post-auth state and output. -func getAndSetAccessTokenJSON(ctx context.Context, orgId *string, wait bool, sess *SessionState, result *SessionResult) error { +func getAndSetAccessTokenJSON(ctx context.Context, orgId *string, wait bool, ssoConnection *string, sess *SessionState, result *SessionResult) error { if sess == nil { // No pre-fetched session — look one up now. var err error @@ -242,17 +268,6 @@ func getAndSetAccessTokenJSON(ctx context.Context, orgId *string, wait bool, ses return fmt.Errorf("error creating new auth verifier and challenge: %w", err) } - var ssoConnection *string - if orgId != nil && *orgId != "" { - conn, err := FetchSSOConnection(ctx, *orgId) - if err != nil { - log.Debug().Err(err).Msg("SSO connection lookup failed, proceeding without connection param") - } - if conn != "" { - ssoConnection = &conn - } - } - authURL, err := a.GetAuthURL(ctx, csrfState, challenge, orgId, ssoConnection) if err != nil { return fmt.Errorf("error getting auth URL: %w", err) @@ -385,7 +400,7 @@ func printPendingJSON(authURL, sessionId string) { // getAndSetAccessTokenInteractive is the original interactive path: inline callback server, // optional [Enter]-to-open-browser prompt when stdin is a TTY. -func getAndSetAccessTokenInteractive(ctx context.Context, orgId *string) error { +func getAndSetAccessTokenInteractive(ctx context.Context, orgId *string, ssoConnection *string) error { // If a daemon from a prior JSON-mode login exists, check whether it has // already finished before deciding whether to block interactive login. sess, result, err := findResumableSession() @@ -413,17 +428,6 @@ func getAndSetAccessTokenInteractive(ctx context.Context, orgId *string) error { return fmt.Errorf("error creating new auth verifier and challenge: %w", err) } - var ssoConnection *string - if orgId != nil && *orgId != "" { - conn, err := FetchSSOConnection(ctx, *orgId) - if err != nil { - log.Debug().Err(err).Msg("SSO connection lookup failed, proceeding without connection param") - } - if conn != "" { - ssoConnection = &conn - } - } - authURL, err := a.GetAuthURL(ctx, csrfState, challenge, orgId, ssoConnection) if err != nil { return fmt.Errorf("error getting auth URL: %w", err) From b44267b954524ba65021ef3155e6c9fa80954cad Mon Sep 17 00:00:00 2001 From: Austin DeNoble Date: Tue, 14 Apr 2026 10:20:43 -0400 Subject: [PATCH 03/15] extract ResolveSSOConnection --- internal/pkg/cli/command/target/target.go | 16 ++-------------- internal/pkg/utils/login/login.go | 8 +------- internal/pkg/utils/login/sso.go | 14 ++++++++++++++ 3 files changed, 17 insertions(+), 21 deletions(-) diff --git a/internal/pkg/cli/command/target/target.go b/internal/pkg/cli/command/target/target.go index 35f6283..bdb0204 100644 --- a/internal/pkg/cli/command/target/target.go +++ b/internal/pkg/cli/command/target/target.go @@ -201,13 +201,7 @@ func NewTargetCmd() *cobra.Command { if currentTokenOrgId != "" && currentTokenOrgId != targetOrg.Id { // Fetch SSO connection while the current token is still valid, // before logout clears it. - var ssoConn *string - conn, connErr := login.FetchSSOConnection(ctx, targetOrg.Id) - if connErr != nil { - log.Debug().Err(connErr).Msg("SSO connection lookup failed, proceeding without connection param") - } else if conn != "" { - ssoConn = &conn - } + ssoConn := login.ResolveSSOConnection(ctx, targetOrg.Id) oauth.Logout() err = login.GetAndSetAccessToken(ctx, &targetOrg.Id, login.Options{Json: options.json, Wait: true, SSOConnection: ssoConn}) if err != nil { @@ -256,13 +250,7 @@ func NewTargetCmd() *cobra.Command { if currentTokenOrgId != org.Id { // Fetch SSO connection while the current token is still valid, // before logout clears it. - var ssoConn *string - conn, connErr := login.FetchSSOConnection(ctx, org.Id) - if connErr != nil { - log.Debug().Err(connErr).Msg("SSO connection lookup failed, proceeding without connection param") - } else if conn != "" { - ssoConn = &conn - } + ssoConn := login.ResolveSSOConnection(ctx, org.Id) oauth.Logout() err = login.GetAndSetAccessToken(ctx, &org.Id, login.Options{Json: options.json, Wait: true, SSOConnection: ssoConn}) if err != nil { diff --git a/internal/pkg/utils/login/login.go b/internal/pkg/utils/login/login.go index a52adcd..1548a2b 100644 --- a/internal/pkg/utils/login/login.go +++ b/internal/pkg/utils/login/login.go @@ -98,13 +98,7 @@ func Run(ctx context.Context, opts Options) { } if differentOrg { - conn, lookupErr := FetchSSOConnection(ctx, *opts.OrgId) - if lookupErr != nil { - log.Debug().Err(lookupErr).Msg("SSO connection lookup failed, proceeding without connection param") - } - if conn != "" { - opts.SSOConnection = &conn - } + opts.SSOConnection = ResolveSSOConnection(ctx, *opts.OrgId) oauth.Logout() // Fall through to GetAndSetAccessToken. } else { diff --git a/internal/pkg/utils/login/sso.go b/internal/pkg/utils/login/sso.go index 358f4f0..80ce16c 100644 --- a/internal/pkg/utils/login/sso.go +++ b/internal/pkg/utils/login/sso.go @@ -23,6 +23,20 @@ type dashboardOrgsResponse struct { NewOrgs []dashboardOrg `json:"newOrgs"` } +// ResolveSSOConnection is a convenience wrapper around FetchSSOConnection that +// returns a pointer to the connection name when SSO is enforced for the org, or +// nil otherwise. Errors are logged at debug level and treated as "no SSO". +func ResolveSSOConnection(ctx context.Context, orgId string) *string { + conn, err := FetchSSOConnection(ctx, orgId) + if err != nil { + log.Debug().Err(err).Str("orgId", orgId).Msg("SSO connection lookup failed, proceeding without connection param") + } + if conn == "" { + return nil + } + return &conn +} + // FetchSSOConnection calls the private dashboard API to retrieve the Auth0 // connection name for the given orgId. It returns ("", nil) when the org has // no SSO configured, enforce_sso_authentication is false, or any error occurs. From 413f932bc02327f438061e40a805ae534079b5ce Mon Sep 17 00:00:00 2001 From: Austin DeNoble Date: Tue, 14 Apr 2026 10:25:04 -0400 Subject: [PATCH 04/15] pass opts.OrgId and ops.SSOCOnnection to getAndSetAccessTokenJSON --- internal/pkg/utils/login/login.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/pkg/utils/login/login.go b/internal/pkg/utils/login/login.go index 1548a2b..7fb9878 100644 --- a/internal/pkg/utils/login/login.go +++ b/internal/pkg/utils/login/login.go @@ -69,7 +69,7 @@ func Run(ctx context.Context, opts Options) { exit.Error(err, "Error checking for existing auth session") } if sess != nil { - if err := getAndSetAccessTokenJSON(ctx, nil, false, nil, sess, result); err != nil { + if err := getAndSetAccessTokenJSON(ctx, opts.OrgId, false, opts.SSOConnection, sess, result); err != nil { msg.FailMsg("Error acquiring access token while logging in: %s", err) exit.Error(err, "Error acquiring access token while logging in") } From 2aae05627484927c85c0b346295614bb01e22ad7 Mon Sep 17 00:00:00 2001 From: Austin DeNoble Date: Thu, 16 Apr 2026 14:15:03 -0400 Subject: [PATCH 05/15] add SSOConnection to SessionState, thread it through pollForResult, implement finishAuthWithSSO and call in resumeSession, pollForResult, make sure getAndSetAccessTokenInteractive handles 2-step SSO properly --- internal/pkg/utils/login/login.go | 67 +++++++++++++++++++++-------- internal/pkg/utils/login/session.go | 6 ++- 2 files changed, 55 insertions(+), 18 deletions(-) diff --git a/internal/pkg/utils/login/login.go b/internal/pkg/utils/login/login.go index 7fb9878..fa41c38 100644 --- a/internal/pkg/utils/login/login.go +++ b/internal/pkg/utils/login/login.go @@ -269,11 +269,12 @@ func getAndSetAccessTokenJSON(ctx context.Context, orgId *string, wait bool, sso sessionId := newSessionId() newSess := &SessionState{ - SessionId: sessionId, - CSRFState: csrfState, - AuthURL: authURL, - OrgId: orgId, - CreatedAt: time.Now(), + SessionId: sessionId, + CSRFState: csrfState, + AuthURL: authURL, + OrgId: orgId, + SSOConnection: ssoConnection, + CreatedAt: time.Now(), } if err := writeSessionState(*newSess); err != nil { return fmt.Errorf("error writing session state: %w", err) @@ -290,7 +291,7 @@ func getAndSetAccessTokenJSON(ctx context.Context, orgId *string, wait bool, sso // Print the auth URL to stderr only: stdout must stay clean so the caller // can emit a single JSON document once this function returns. fmt.Fprintf(os.Stderr, "Visit the following URL to authenticate:\n\n %s\n\n", authURL) - return pollForResult(sessionId, newSess.CreatedAt, true) + return pollForResult(sessionId, newSess.CreatedAt, true, ssoConnection) } // Agentic login (first call): print pending and return immediately. The daemon @@ -300,6 +301,31 @@ func getAndSetAccessTokenJSON(ctx context.Context, orgId *string, wait bool, sso return nil } +// finishAuthWithSSO is called when a login session completes successfully. +// If ssoConnection is non-nil, the session was already an SSO round — emit +// authenticated JSON directly. Otherwise check whether SSO is enforced for +// the authenticated org; if so, log out and start a second SSO login round +// (emitting a new pending JSON for agents to follow). +func finishAuthWithSSO(ctx context.Context, ssoConnection *string) error { + if ssoConnection == nil { + // Round 1 — check whether SSO enforcement is needed. + token, _ := oauth.Token(ctx) + if token != nil && token.AccessToken != "" { + if claims, err := oauth.ParseClaimsUnverified(token); err == nil { + if conn := ResolveSSOConnection(ctx, claims.OrgId); conn != nil { + // SSO enforced — clear credentials and start the SSO round. + oauth.Logout() + return getAndSetAccessTokenJSON(ctx, &claims.OrgId, false, conn, nil, nil) + } + } + } + } + // Already in SSO round, or SSO not enforced — emit authenticated JSON. + setupCtx, cancel := context.WithTimeout(ctx, 30*time.Second) + defer cancel() + return RunPostAuthSetup(setupCtx) +} + // resumeSession handles a session that was already started (e.g. after a process restart). // If the daemon already finished, it handles the result immediately. Otherwise it polls. // When wait is true, RunPostAuthSetup is skipped; the caller owns post-auth state and output. @@ -314,14 +340,12 @@ func resumeSession(sess *SessionState, result *SessionResult, wait bool) error { if wait { return nil } - setupCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second) - defer cancel() - return RunPostAuthSetup(setupCtx) + return finishAuthWithSSO(context.Background(), sess.SSOConnection) } // Still pending — poll until the daemon completes. // Don't re-emit pending here: this call will block until done, keeping stdout // to a single JSON value per invocation. - return pollForResult(sess.SessionId, sess.CreatedAt, wait) + return pollForResult(sess.SessionId, sess.CreatedAt, wait, sess.SSOConnection) } // pollForResult polls the daemon's result file until auth completes or the session expires. @@ -335,7 +359,7 @@ func resumeSession(sess *SessionState, result *SessionResult, wait bool) error { // // The polling loop runs on context.Background() so that the root command's --timeout // flag (default 60s) does not interrupt a user still authenticating in the browser. -func pollForResult(sessionId string, createdAt time.Time, wait bool) error { +func pollForResult(sessionId string, createdAt time.Time, wait bool, ssoConnection *string) error { ticker := time.NewTicker(time.Second) defer ticker.Stop() remaining := time.Until(createdAt.Add(sessionMaxAge)) @@ -369,11 +393,7 @@ func pollForResult(sessionId string, createdAt time.Time, wait bool) error { // Caller handles post-auth state and output. return nil } - // Use a fresh context for the post-auth API calls: the original ctx may - // have expired if the user took longer than --timeout to authenticate. - setupCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second) - defer cancel() - return RunPostAuthSetup(setupCtx) + return finishAuthWithSSO(context.Background(), ssoConnection) } } } @@ -428,7 +448,10 @@ func getAndSetAccessTokenInteractive(ctx context.Context, orgId *string, ssoConn } codeCh := make(chan string, 1) - serverCtx, cancel := context.WithTimeout(ctx, sessionMaxAge) + // Use context.Background() so the root command's --timeout flag (default 60s) + // does not cut off a user who is still authenticating in the browser. Each + // interactive round gets a fresh sessionMaxAge window, matching the daemon path. + serverCtx, cancel := context.WithTimeout(context.Background(), sessionMaxAge) defer cancel() go func() { @@ -501,6 +524,16 @@ func getAndSetAccessTokenInteractive(ctx context.Context, orgId *string, ssoConn }) } + // Round 1 — check whether SSO enforcement is needed. + // ssoConnection being non-nil means we're already in the SSO round; skip. + if ssoConnection == nil { + if conn := ResolveSSOConnection(ctx, claims.OrgId); conn != nil { + fmt.Fprintf(os.Stderr, "\nSSO is required for your organization. Re-authenticating with your identity provider...\n") + oauth.Logout() + return getAndSetAccessTokenInteractive(ctx, &claims.OrgId, conn) + } + } + return nil } diff --git a/internal/pkg/utils/login/session.go b/internal/pkg/utils/login/session.go index cf2d2f5..a048686 100644 --- a/internal/pkg/utils/login/session.go +++ b/internal/pkg/utils/login/session.go @@ -17,7 +17,11 @@ type SessionState struct { CSRFState string `json:"csrf_state"` AuthURL string `json:"auth_url"` OrgId *string `json:"org_id,omitempty"` - CreatedAt time.Time `json:"created_at"` + // SSOConnection is set on the second-round SSO session. A non-nil value + // means this session was started specifically for SSO enforcement, so the + // completion handler should skip the SSO check and emit "authenticated". + SSOConnection *string `json:"sso_connection,omitempty"` + CreatedAt time.Time `json:"created_at"` } type SessionResult struct { From 6d03993b5d09af0bf58de00973a26fd33aa14023 Mon Sep 17 00:00:00 2001 From: Austin DeNoble Date: Thu, 16 Apr 2026 14:34:25 -0400 Subject: [PATCH 06/15] pass sessionId to finishAuthWithSSO, make sure to call CleanupSession before calling oauth.Logout() --- internal/pkg/utils/login/login.go | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/internal/pkg/utils/login/login.go b/internal/pkg/utils/login/login.go index fa41c38..4646f68 100644 --- a/internal/pkg/utils/login/login.go +++ b/internal/pkg/utils/login/login.go @@ -306,14 +306,22 @@ func getAndSetAccessTokenJSON(ctx context.Context, orgId *string, wait bool, sso // authenticated JSON directly. Otherwise check whether SSO is enforced for // the authenticated org; if so, log out and start a second SSO login round // (emitting a new pending JSON for agents to follow). -func finishAuthWithSSO(ctx context.Context, ssoConnection *string) error { +// +// sessionId is the just-completed session. When starting an SSO round it is +// cleaned up eagerly before calling getAndSetAccessTokenJSON, so that +// findResumableSession does not pick up the stale session and resume it +// instead of starting the new SSO flow. The deferred CleanupSession in the +// caller is a no-op once the files are already removed. +func finishAuthWithSSO(ctx context.Context, sessionId string, ssoConnection *string) error { if ssoConnection == nil { // Round 1 — check whether SSO enforcement is needed. token, _ := oauth.Token(ctx) if token != nil && token.AccessToken != "" { if claims, err := oauth.ParseClaimsUnverified(token); err == nil { if conn := ResolveSSOConnection(ctx, claims.OrgId); conn != nil { - // SSO enforced — clear credentials and start the SSO round. + // Clean up the completed session before starting the SSO round + // so findResumableSession won't find and re-resume it. + CleanupSession(sessionId) oauth.Logout() return getAndSetAccessTokenJSON(ctx, &claims.OrgId, false, conn, nil, nil) } @@ -340,7 +348,7 @@ func resumeSession(sess *SessionState, result *SessionResult, wait bool) error { if wait { return nil } - return finishAuthWithSSO(context.Background(), sess.SSOConnection) + return finishAuthWithSSO(context.Background(), sess.SessionId, sess.SSOConnection) } // Still pending — poll until the daemon completes. // Don't re-emit pending here: this call will block until done, keeping stdout @@ -393,7 +401,7 @@ func pollForResult(sessionId string, createdAt time.Time, wait bool, ssoConnecti // Caller handles post-auth state and output. return nil } - return finishAuthWithSSO(context.Background(), ssoConnection) + return finishAuthWithSSO(context.Background(), sessionId, ssoConnection) } } } From 7d165148fa17e5af7d30d5d73446fc18a7e809f4 Mon Sep 17 00:00:00 2001 From: Austin DeNoble Date: Thu, 16 Apr 2026 14:50:15 -0400 Subject: [PATCH 07/15] clean up request context for auth code exchange and SSO lookup operations --- internal/pkg/utils/login/login.go | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/internal/pkg/utils/login/login.go b/internal/pkg/utils/login/login.go index 4646f68..6b96382 100644 --- a/internal/pkg/utils/login/login.go +++ b/internal/pkg/utils/login/login.go @@ -506,7 +506,14 @@ func getAndSetAccessTokenInteractive(ctx context.Context, orgId *string, ssoConn return errors.New("error authenticating CLI and retrieving oauth2 access token") } - token, err := a.ExchangeAuthCode(ctx, verifier, code) + // Use a fresh context for post-callback network operations. The original ctx + // may have already exceeded the root command's 60s timeout if the user took + // a while to authenticate — the server accepted the callback successfully, so + // we must not let a stale deadline fail the code exchange or SSO lookup. + apiCtx, apiCancel := context.WithTimeout(context.Background(), 30*time.Second) + defer apiCancel() + + token, err := a.ExchangeAuthCode(apiCtx, verifier, code) if err != nil { return fmt.Errorf("error exchanging auth code for access token: %w", err) } @@ -535,7 +542,7 @@ func getAndSetAccessTokenInteractive(ctx context.Context, orgId *string, ssoConn // Round 1 — check whether SSO enforcement is needed. // ssoConnection being non-nil means we're already in the SSO round; skip. if ssoConnection == nil { - if conn := ResolveSSOConnection(ctx, claims.OrgId); conn != nil { + if conn := ResolveSSOConnection(apiCtx, claims.OrgId); conn != nil { fmt.Fprintf(os.Stderr, "\nSSO is required for your organization. Re-authenticating with your identity provider...\n") oauth.Logout() return getAndSetAccessTokenInteractive(ctx, &claims.OrgId, conn) From 6e5aefed03fd39392056e3268aa3b2928c5250dc Mon Sep 17 00:00:00 2001 From: Austin DeNoble Date: Thu, 16 Apr 2026 15:51:18 -0400 Subject: [PATCH 08/15] fix silent claims parsing --- .claude/settings.local.json | 54 +++++++++++++++++++++++++++++++ internal/pkg/utils/login/login.go | 7 ++-- 2 files changed, 59 insertions(+), 2 deletions(-) create mode 100644 .claude/settings.local.json diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..e8fc9ba --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,54 @@ +{ + "permissions": { + "allow": [ + "mcp__claude_ai_Linear__save_issue", + "mcp__claude_ai_Linear__get_issue", + "Bash(go test:*)", + "Bash(go build:*)", + "Bash(gh pr:*)", + "Bash(gh api:*)", + "Bash(goimports:*)", + "Bash(grep -r pcio . --include=*.go)", + "Bash(xargs sed:*)", + "mcp__claude_ai_Linear__list_teams", + "mcp__claude_ai_Linear__list_issue_statuses", + "Bash(grep:*)", + "Bash(python3:*)", + "Bash(while read:*)", + "Bash(do if:*)", + "Bash(then echo:*)", + "Bash(go mod:*)", + "Bash(go get:*)", + "Bash(go list:*)", + "mcp__claude_ai_Linear__list_projects", + "mcp__claude_ai_Linear__list_issue_labels", + "WebFetch(domain:github.com)", + "WebFetch(domain:raw.githubusercontent.com)", + "WebFetch(domain:api.github.com)", + "WebFetch(domain:brew.sh)", + "WebFetch(domain:docs.brew.sh)", + "WebFetch(domain:goreleaser.com)", + "WebSearch", + "Bash(/Users/austin/workspace/cli/dist/pc_darwin_arm64_v8.0/pc login:*)", + "Bash(go run:*)", + "Bash(/tmp/pc_test auth:*)", + "Bash(/tmp/pc_test login:*)", + "Bash(echo \"exit: $?\")", + "Bash(./dist/pc_darwin_all/pc login:*)", + "Bash(./dist/pc_darwin_all/pc auth:*)", + "Read(//Users/austin/.config/pinecone/sessions/**)", + "Bash(gh release:*)", + "WebFetch(domain:auth0.com)", + "mcp__plugin_using-linear_linear-server__get_issue", + "mcp__plugin_using-linear_linear-server__list_comments", + "mcp__plugin_using-linear_linear-server__list_issue_statuses", + "mcp__plugin_using-linear_linear-server__save_issue", + "Bash(xargs cat:*)", + "Bash(xargs -I {} basename {})", + "Bash(just test-unit:*)", + "Bash(just --list)", + "Bash(echo \"Exit: $?\")", + "Bash(find:*)" + ] + } +} diff --git a/internal/pkg/utils/login/login.go b/internal/pkg/utils/login/login.go index 6b96382..f3568ad 100644 --- a/internal/pkg/utils/login/login.go +++ b/internal/pkg/utils/login/login.go @@ -90,8 +90,11 @@ func Run(ctx context.Context, opts Options) { // If --org targets a different organization, re-authenticate now while // the token is still valid so we can look up the SSO connection before // clearing credentials. - differentOrg := false - if opts.OrgId != nil && *opts.OrgId != "" { + // Default to true when --org is explicitly set: if claims parsing fails + // we can't confirm the current org matches, so re-authenticating is the + // safer choice (and will resolve a malformed token situation). + differentOrg := opts.OrgId != nil && *opts.OrgId != "" + if differentOrg { if claims, claimsErr := oauth.ParseClaimsUnverified(token); claimsErr == nil { differentOrg = claims.OrgId != *opts.OrgId } From 4a5b55a4490b93c5bf20de9b6e1cd8ec4200c90a Mon Sep 17 00:00:00 2001 From: Austin DeNoble Date: Thu, 16 Apr 2026 15:51:41 -0400 Subject: [PATCH 09/15] remove locla claude --- .claude/settings.local.json | 54 ------------------------------------- 1 file changed, 54 deletions(-) delete mode 100644 .claude/settings.local.json diff --git a/.claude/settings.local.json b/.claude/settings.local.json deleted file mode 100644 index e8fc9ba..0000000 --- a/.claude/settings.local.json +++ /dev/null @@ -1,54 +0,0 @@ -{ - "permissions": { - "allow": [ - "mcp__claude_ai_Linear__save_issue", - "mcp__claude_ai_Linear__get_issue", - "Bash(go test:*)", - "Bash(go build:*)", - "Bash(gh pr:*)", - "Bash(gh api:*)", - "Bash(goimports:*)", - "Bash(grep -r pcio . --include=*.go)", - "Bash(xargs sed:*)", - "mcp__claude_ai_Linear__list_teams", - "mcp__claude_ai_Linear__list_issue_statuses", - "Bash(grep:*)", - "Bash(python3:*)", - "Bash(while read:*)", - "Bash(do if:*)", - "Bash(then echo:*)", - "Bash(go mod:*)", - "Bash(go get:*)", - "Bash(go list:*)", - "mcp__claude_ai_Linear__list_projects", - "mcp__claude_ai_Linear__list_issue_labels", - "WebFetch(domain:github.com)", - "WebFetch(domain:raw.githubusercontent.com)", - "WebFetch(domain:api.github.com)", - "WebFetch(domain:brew.sh)", - "WebFetch(domain:docs.brew.sh)", - "WebFetch(domain:goreleaser.com)", - "WebSearch", - "Bash(/Users/austin/workspace/cli/dist/pc_darwin_arm64_v8.0/pc login:*)", - "Bash(go run:*)", - "Bash(/tmp/pc_test auth:*)", - "Bash(/tmp/pc_test login:*)", - "Bash(echo \"exit: $?\")", - "Bash(./dist/pc_darwin_all/pc login:*)", - "Bash(./dist/pc_darwin_all/pc auth:*)", - "Read(//Users/austin/.config/pinecone/sessions/**)", - "Bash(gh release:*)", - "WebFetch(domain:auth0.com)", - "mcp__plugin_using-linear_linear-server__get_issue", - "mcp__plugin_using-linear_linear-server__list_comments", - "mcp__plugin_using-linear_linear-server__list_issue_statuses", - "mcp__plugin_using-linear_linear-server__save_issue", - "Bash(xargs cat:*)", - "Bash(xargs -I {} basename {})", - "Bash(just test-unit:*)", - "Bash(just --list)", - "Bash(echo \"Exit: $?\")", - "Bash(find:*)" - ] - } -} From 71430995e4dd6c82e284a640ce33d36779df961b Mon Sep 17 00:00:00 2001 From: Austin DeNoble Date: Mon, 20 Apr 2026 12:18:15 -0400 Subject: [PATCH 10/15] make sure the lazy auth route for agentic/headless environments properly handles SSO, surfacing an error when an extra browser verification step is needed --- internal/pkg/utils/login/login.go | 32 +++++++++++++++++++++++++------ 1 file changed, 26 insertions(+), 6 deletions(-) diff --git a/internal/pkg/utils/login/login.go b/internal/pkg/utils/login/login.go index f3568ad..acde318 100644 --- a/internal/pkg/utils/login/login.go +++ b/internal/pkg/utils/login/login.go @@ -241,8 +241,10 @@ func getAndSetAccessTokenJSON(ctx context.Context, orgId *string, wait bool, sso } if sess != nil { // If the caller is requesting a specific org that doesn't match the pending - // session's org, the existing session cannot be used. - if orgId != nil && sess.OrgId != nil && *orgId != *sess.OrgId { + // session's org, the existing session cannot be used. A session with no + // recorded org (sess.OrgId == nil) is also a mismatch when orgId is set, + // since it was started without an org constraint and may yield the wrong org. + if orgId != nil && (sess.OrgId == nil || *orgId != *sess.OrgId) { if result != nil { // Daemon has finished and released the port — clean up and start fresh. CleanupSession(sess.SessionId) @@ -781,15 +783,33 @@ func EnsureAuthenticated(ctx context.Context) error { } // Daemon finished. - defer CleanupSession(sess.SessionId) if result.Status == "error" { + defer CleanupSession(sess.SessionId) return fmt.Errorf("authentication failed: %s. Run %s to try again.", result.Error, style.Code("pc login")) } - // Reload credentials written by the daemon process into this process's cache, - // then set the target org/project context so the calling command can proceed - // without a separate `pc login` or `pc target` call. + // Reload credentials so we can check SSO enforcement before finalising. _ = secrets.SecretsViper.ReadInConfig() + + // If this was a round-1 session (no SSO connection recorded), check whether + // the org requires SSO before handing off to the calling command. + // When SSO is required we do NOT clean up the session or clear the token: + // the next `pc login -j` call will find the still-alive session, resume it + // via finishAuthWithSSO, detect SSO, and emit a new pending URL for the SSO + // round — so the user only has to complete one more browser step, not two. + if sess.SSOConnection == nil { + token, _ := oauth.Token(ctx) + if token != nil && token.AccessToken != "" { + if claims, claimsErr := oauth.ParseClaimsUnverified(token); claimsErr == nil { + if ResolveSSOConnection(ctx, claims.OrgId) != nil { + return fmt.Errorf("SSO authentication is required for this organization. Run %s to complete authentication.", style.Code("pc login")) + } + } + } + } + + // No SSO required (or already in SSO round) — finalise lazy completion. + defer CleanupSession(sess.SessionId) if _, err := applyAuthContext(ctx); err != nil { // Non-fatal: credentials are valid, context setup is best-effort. log.Debug().Err(err).Msg("EnsureAuthenticated: applyAuthContext failed after lazy credential reload") From a8ae490831befcce07875d3a2e5e4688aaa927d8 Mon Sep 17 00:00:00 2001 From: Austin DeNoble Date: Mon, 20 Apr 2026 12:39:11 -0400 Subject: [PATCH 11/15] fix nil-pointer dereference in getAndSetAccessTokenJSON --- internal/pkg/utils/login/login.go | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/internal/pkg/utils/login/login.go b/internal/pkg/utils/login/login.go index acde318..660b0af 100644 --- a/internal/pkg/utils/login/login.go +++ b/internal/pkg/utils/login/login.go @@ -251,7 +251,11 @@ func getAndSetAccessTokenJSON(ctx context.Context, orgId *string, wait bool, sso // Fall through to start a new flow. } else { // Daemon is still running and holds the callback port. - return fmt.Errorf("an auth session for a different organization (%s) is already in progress; wait for it to expire or complete it first", *sess.OrgId) + sessOrg := "unknown" + if sess.OrgId != nil { + sessOrg = *sess.OrgId + } + return fmt.Errorf("an auth session for a different organization (%s) is already in progress; wait for it to expire or complete it first", sessOrg) } } else { return resumeSession(sess, result, wait) From 71480ff5e0157a512a4fda6bef108d413f237418 Mon Sep 17 00:00:00 2001 From: Austin DeNoble Date: Mon, 20 Apr 2026 13:10:09 -0400 Subject: [PATCH 12/15] make sure a partial login awaiting SSO isn't broken by the interactive login path, gracefull resume. prevent swallowing claims parsing error --- internal/pkg/utils/login/login.go | 21 +++++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/internal/pkg/utils/login/login.go b/internal/pkg/utils/login/login.go index 660b0af..ee843a3 100644 --- a/internal/pkg/utils/login/login.go +++ b/internal/pkg/utils/login/login.go @@ -100,12 +100,28 @@ func Run(ctx context.Context, opts Options) { } } + needsReauth := false if differentOrg { opts.SSOConnection = ResolveSSOConnection(ctx, *opts.OrgId) oauth.Logout() - // Fall through to GetAndSetAccessToken. + needsReauth = true + } else if claims, claimsErr := oauth.ParseClaimsUnverified(token); claimsErr != nil { + // Can't parse the existing token — log it, but there is no orgId to + // look up SSO against, so fall through and treat as already logged in. + log.Debug().Err(claimsErr).Msg("Run: could not parse existing token claims; skipping SSO enforcement check") } else { - // Same org (or no --org flag) — show "already logged in". + // Same org (or no --org flag) — check whether SSO is now enforced. + if conn := ResolveSSOConnection(ctx, claims.OrgId); conn != nil { + opts.SSOConnection = conn + orgId := claims.OrgId + opts.OrgId = &orgId + oauth.Logout() + needsReauth = true + } + } + + if !needsReauth { + // Genuinely already authenticated, no SSO enforcement — show "already logged in". if opts.Json { claims, err := oauth.ParseClaimsUnverified(token) if err == nil { @@ -124,6 +140,7 @@ func Run(ctx context.Context, opts Options) { } return } + // Fall through to GetAndSetAccessToken. } err = GetAndSetAccessToken(ctx, opts.OrgId, opts) From 820f574be491eb0c36e99fff813a04506e44a475 Mon Sep 17 00:00:00 2001 From: Austin DeNoble Date: Mon, 20 Apr 2026 14:46:33 -0400 Subject: [PATCH 13/15] cancel outer goroutine watching stdin for interactive login when SSO is initiated --- internal/pkg/utils/login/login.go | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/internal/pkg/utils/login/login.go b/internal/pkg/utils/login/login.go index ee843a3..9794e14 100644 --- a/internal/pkg/utils/login/login.go +++ b/internal/pkg/utils/login/login.go @@ -571,6 +571,10 @@ func getAndSetAccessTokenInteractive(ctx context.Context, orgId *string, ssoConn if conn := ResolveSSOConnection(apiCtx, claims.OrgId); conn != nil { fmt.Fprintf(os.Stderr, "\nSSO is required for your organization. Re-authenticating with your identity provider...\n") oauth.Logout() + // Cancel the outer serverCtx before starting the SSO round so the + // stdin-watching goroutine above exits via ctx.Done() and cannot race + // with the new round's goroutine for the next Enter keypress. + cancel() return getAndSetAccessTokenInteractive(ctx, &claims.OrgId, conn) } } From b9ea0ec40271d00dea7c5a747f52c453feada740 Mon Sep 17 00:00:00 2001 From: Austin DeNoble Date: Mon, 20 Apr 2026 15:39:45 -0400 Subject: [PATCH 14/15] remove explicit --org flag from login, clean up wiring --- internal/pkg/cli/command/auth/login.go | 11 +------ internal/pkg/cli/command/login/login.go | 11 +------ internal/pkg/utils/login/login.go | 39 +++++-------------------- 3 files changed, 10 insertions(+), 51 deletions(-) diff --git a/internal/pkg/cli/command/auth/login.go b/internal/pkg/cli/command/auth/login.go index 0916590..1b264f3 100644 --- a/internal/pkg/cli/command/auth/login.go +++ b/internal/pkg/cli/command/auth/login.go @@ -49,7 +49,6 @@ var ( func NewLoginCmd() *cobra.Command { var jsonOutput bool - var orgId string cmd := &cobra.Command{ Use: "login", @@ -59,9 +58,6 @@ func NewLoginCmd() *cobra.Command { # Interactive login (opens a browser) pc auth login - # Login scoped to a specific organization (enables SSO routing) - pc auth login --org "ORG_ID" - # Agentic login — first call returns a pending URL pc auth login --json @@ -70,16 +66,11 @@ func NewLoginCmd() *cobra.Command { `), GroupID: help.GROUP_AUTH.ID, Run: func(cmd *cobra.Command, args []string) { - opts := login.Options{Json: jsonOutput} - if cmd.Flags().Changed("org") { - opts.OrgId = &orgId - } - login.Run(cmd.Context(), opts) + login.Run(cmd.Context(), login.Options{Json: jsonOutput}) }, } cmd.Flags().BoolVarP(&jsonOutput, "json", "j", false, "emit JSON output") - cmd.Flags().StringVar(&orgId, "org", "", "Organization ID to authenticate into (enables SSO routing for organizations with SSO enforced)") return cmd } diff --git a/internal/pkg/cli/command/login/login.go b/internal/pkg/cli/command/login/login.go index efcc567..40a6802 100644 --- a/internal/pkg/cli/command/login/login.go +++ b/internal/pkg/cli/command/login/login.go @@ -41,7 +41,6 @@ var ( func NewLoginCmd() *cobra.Command { var jsonOutput bool - var orgId string cmd := &cobra.Command{ Use: "login", @@ -51,9 +50,6 @@ func NewLoginCmd() *cobra.Command { # Interactive login (opens a browser) pc login - # Login scoped to a specific organization (enables SSO routing) - pc login --org "ORG_ID" - # Agentic login — first call returns a pending URL pc login --json @@ -62,16 +58,11 @@ func NewLoginCmd() *cobra.Command { `), GroupID: help.GROUP_AUTH.ID, Run: func(cmd *cobra.Command, args []string) { - opts := login.Options{Json: jsonOutput} - if cmd.Flags().Changed("org") { - opts.OrgId = &orgId - } - login.Run(cmd.Context(), opts) + login.Run(cmd.Context(), login.Options{Json: jsonOutput}) }, } cmd.Flags().BoolVarP(&jsonOutput, "json", "j", false, "emit JSON output") - cmd.Flags().StringVar(&orgId, "org", "", "Organization ID to authenticate into (enables SSO routing for organizations with SSO enforced)") return cmd } diff --git a/internal/pkg/utils/login/login.go b/internal/pkg/utils/login/login.go index 9794e14..1a7d5f3 100644 --- a/internal/pkg/utils/login/login.go +++ b/internal/pkg/utils/login/login.go @@ -46,8 +46,6 @@ type Options struct { // RunPostAuthSetup is not called in Wait mode; the caller is responsible // for any post-auth state setup and output. Wait bool - // OrgId pins the login flow to a specific organization. - OrgId *string // SSOConnection is the Auth0 connection name to pass as `connection=` in the // authorization URL, routing the browser directly to the org's IdP. // Callers that hold a valid token before clearing credentials (e.g. pc target) @@ -69,7 +67,7 @@ func Run(ctx context.Context, opts Options) { exit.Error(err, "Error checking for existing auth session") } if sess != nil { - if err := getAndSetAccessTokenJSON(ctx, opts.OrgId, false, opts.SSOConnection, sess, result); err != nil { + if err := getAndSetAccessTokenJSON(ctx, nil, false, opts.SSOConnection, sess, result); err != nil { msg.FailMsg("Error acquiring access token while logging in: %s", err) exit.Error(err, "Error acquiring access token while logging in") } @@ -87,37 +85,16 @@ func Run(ctx context.Context, opts Options) { } if !expired && token != nil && token.AccessToken != "" { - // If --org targets a different organization, re-authenticate now while - // the token is still valid so we can look up the SSO connection before - // clearing credentials. - // Default to true when --org is explicitly set: if claims parsing fails - // we can't confirm the current org matches, so re-authenticating is the - // safer choice (and will resolve a malformed token situation). - differentOrg := opts.OrgId != nil && *opts.OrgId != "" - if differentOrg { - if claims, claimsErr := oauth.ParseClaimsUnverified(token); claimsErr == nil { - differentOrg = claims.OrgId != *opts.OrgId - } - } - + // Check whether SSO is now enforced for the current org. needsReauth := false - if differentOrg { - opts.SSOConnection = ResolveSSOConnection(ctx, *opts.OrgId) - oauth.Logout() - needsReauth = true - } else if claims, claimsErr := oauth.ParseClaimsUnverified(token); claimsErr != nil { + if claims, claimsErr := oauth.ParseClaimsUnverified(token); claimsErr != nil { // Can't parse the existing token — log it, but there is no orgId to // look up SSO against, so fall through and treat as already logged in. log.Debug().Err(claimsErr).Msg("Run: could not parse existing token claims; skipping SSO enforcement check") - } else { - // Same org (or no --org flag) — check whether SSO is now enforced. - if conn := ResolveSSOConnection(ctx, claims.OrgId); conn != nil { - opts.SSOConnection = conn - orgId := claims.OrgId - opts.OrgId = &orgId - oauth.Logout() - needsReauth = true - } + } else if conn := ResolveSSOConnection(ctx, claims.OrgId); conn != nil { + opts.SSOConnection = conn + oauth.Logout() + needsReauth = true } if !needsReauth { @@ -143,7 +120,7 @@ func Run(ctx context.Context, opts Options) { // Fall through to GetAndSetAccessToken. } - err = GetAndSetAccessToken(ctx, opts.OrgId, opts) + err = GetAndSetAccessToken(ctx, nil, opts) if err != nil { msg.FailMsg("Error acquiring access token while logging in: %s", err) exit.Error(err, "Error acquiring access token while logging in") From f6e92474197e7442851e086f338bc9a34aa111a9 Mon Sep 17 00:00:00 2001 From: Austin DeNoble Date: Mon, 20 Apr 2026 16:01:29 -0400 Subject: [PATCH 15/15] make sure the sso org_id isn't dropped inside of the login run --- internal/pkg/utils/login/login.go | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/internal/pkg/utils/login/login.go b/internal/pkg/utils/login/login.go index 1a7d5f3..2869d4e 100644 --- a/internal/pkg/utils/login/login.go +++ b/internal/pkg/utils/login/login.go @@ -84,6 +84,7 @@ func Run(ctx context.Context, opts Options) { exit.Error(err, "Error retrieving oauth token") } + var ssoOrgId *string if !expired && token != nil && token.AccessToken != "" { // Check whether SSO is now enforced for the current org. needsReauth := false @@ -92,6 +93,8 @@ func Run(ctx context.Context, opts Options) { // look up SSO against, so fall through and treat as already logged in. log.Debug().Err(claimsErr).Msg("Run: could not parse existing token claims; skipping SSO enforcement check") } else if conn := ResolveSSOConnection(ctx, claims.OrgId); conn != nil { + orgId := claims.OrgId + ssoOrgId = &orgId opts.SSOConnection = conn oauth.Logout() needsReauth = true @@ -120,7 +123,7 @@ func Run(ctx context.Context, opts Options) { // Fall through to GetAndSetAccessToken. } - err = GetAndSetAccessToken(ctx, nil, opts) + err = GetAndSetAccessToken(ctx, ssoOrgId, opts) if err != nil { msg.FailMsg("Error acquiring access token while logging in: %s", err) exit.Error(err, "Error acquiring access token while logging in")