From a56dde6c7af113abd4ad94ab72b2f8a98b32148f Mon Sep 17 00:00:00 2001 From: Drk <55439071+darkmore666@users.noreply.github.com> Date: Mon, 9 Nov 2026 17:57:03 +0800 Subject: [PATCH] fix: accept current global userinfo shape in PAT login The global /api/v1/userinfo endpoint now returns id/name/username/avatar and camelCase organization fields instead of uid/organization_tags. PAT exchange succeeded but the UID lookup failed, aborting startup with "could not fetch user info after exchange". Normalize both legacy and current response shapes, mirroring the official CLI field precedence, and include the underlying userinfo error in the failure message so future shape changes remain diagnosable. --- auth.go | 99 ++++++++++++++++++++-- auth_userinfo_shape_test.go | 164 ++++++++++++++++++++++++++++++++++++ main.go | 4 +- 3 files changed, 257 insertions(+), 10 deletions(-) create mode 100644 auth_userinfo_shape_test.go diff --git a/auth.go b/auth.go index 4f1398a..db9e591 100644 --- a/auth.go +++ b/auth.go @@ -1123,15 +1123,94 @@ func (a *authManager) deviceLogin(ctx context.Context, out io.Writer) error { } } +// authUserInfoProfile mirrors the official CLI's tolerant OpenAPI userinfo +// decoder. The endpoint has returned several shapes over time: older responses +// use uid/organization_id/organization_tags, while current responses use +// id/username/avatar/organization and camelCase organization fields. Keep the +// raw variants here and canonicalize once in normalize so callers only deal +// with the stable userInfo field names. type authUserInfoProfile struct { - DataPolicyAgreed *bool `json:"data_policy_agreed"` - IsDataPolicyModifiable *bool `json:"is_data_policy_modifiable"` - UID string `json:"uid"` - Name string `json:"name"` - Email string `json:"email"` - OrgID string `json:"organization_id"` - OrgName string `json:"organization_name"` - OrgTags []string `json:"organization_tags"` + DataPolicyAgreed *bool `json:"data_policy_agreed"` + IsDataPolicyModifiable *bool `json:"is_data_policy_modifiable"` + IsPrivacyPolicyModifiable *bool `json:"is_privacy_policy_modifiable"` + UID string `json:"uid"` + ID string `json:"id"` + UserID string `json:"user_id"` + Name string `json:"name"` + UserName string `json:"username"` + UserNameSnake string `json:"user_name"` + Email string `json:"email"` + AvatarURL string `json:"avatar_url"` + Avatar string `json:"avatar"` + OrgID string `json:"organization_id"` + OrgIDCamel string `json:"orgId"` + OrgIDCamelAlt string `json:"organizationId"` + OrgName string `json:"organization_name"` + OrgNameCamel string `json:"orgName"` + OrgNameCamelAlt string `json:"organizationName"` + Organization *authUserInfoOrganization `json:"organization"` + OrgTags []string `json:"organization_tags"` +} + +type authUserInfoOrganization struct { + ID string `json:"id"` + UID string `json:"uid"` + OrgID string `json:"org_id"` + OrgIDCamel string `json:"orgId"` + OrganizationID string `json:"organization_id"` + Name string `json:"name"` + OrgName string `json:"org_name"` + OrgNameCamel string `json:"orgName"` + OrganizationName string `json:"organization_name"` + Tags []string `json:"tags"` + OrganizationTags []string `json:"organization_tags"` +} + +func firstNonEmptyString(values ...string) string { + for _, value := range values { + if value != "" { + return value + } + } + return "" +} + +func firstNonEmptyStrings(values ...[]string) []string { + for _, value := range values { + if value != nil { + return value + } + } + return nil +} + +// normalize canonicalizes the alternate wire shapes into the fields consumed +// by applyAuthUserInfoProfile and requestPATCandidate. Precedence follows the +// official CLI: id, user_id, uid for identity; name, username, user_name for +// the display name; camelCase then snake_case organization fields; and the +// nested organization object as the final fallback. +func (p *authUserInfoProfile) normalize() { + if p == nil { + return + } + p.UID = firstNonEmptyString(p.ID, p.UserID, p.UID) + p.Name = firstNonEmptyString(p.Name, p.UserName, p.UserNameSnake) + p.AvatarURL = firstNonEmptyString(p.AvatarURL, p.Avatar) + if p.IsDataPolicyModifiable == nil { + p.IsDataPolicyModifiable = p.IsPrivacyPolicyModifiable + } + orgID := firstNonEmptyString(p.OrgIDCamel, p.OrgID, p.OrgIDCamelAlt) + orgName := firstNonEmptyString(p.OrgNameCamel, p.OrgName, p.OrgNameCamelAlt) + tags := p.OrgTags + if org := p.Organization; org != nil { + orgID = firstNonEmptyString(orgID, org.OrgID, org.OrgIDCamel, org.OrganizationID, org.ID, org.UID) + orgName = firstNonEmptyString(orgName, org.OrgName, org.OrgNameCamel, org.OrganizationName, org.Name) + tags = firstNonEmptyStrings(tags, org.Tags, org.OrganizationTags) + } + p.OrgID, p.OrgName = orgID, orgName + if tags != nil { + p.OrgTags = tags + } } func (a *authManager) requestUserInfo(ctx context.Context, token string, held ...*credentialTxn) (authUserInfoProfile, error) { @@ -1176,6 +1255,7 @@ func (a *authManager) requestUserInfo(ctx context.Context, token string, held .. if err := json.Unmarshal(raw, &profile); err != nil { return authUserInfoProfile{}, err } + profile.normalize() return profile, nil } @@ -1198,6 +1278,9 @@ func applyAuthUserInfoProfile(ui *userInfo, profile authUserInfoProfile) { if profile.Name != "" { ui.Name = profile.Name } + if profile.AvatarURL != "" { + ui.AvatarURL = profile.AvatarURL + } if profile.OrgID != "" { ui.OrgID, ui.OrgName = profile.OrgID, profile.OrgName } diff --git a/auth_userinfo_shape_test.go b/auth_userinfo_shape_test.go new file mode 100644 index 0000000..80e18ac --- /dev/null +++ b/auth_userinfo_shape_test.go @@ -0,0 +1,164 @@ +package main + +import ( + "context" + "net/http" + "net/http/httptest" + "path/filepath" + "testing" +) + +func TestAuthUserInfoProfileNormalizesCurrentGlobalShape(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api/v1/userinfo" { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{ + "id":"synthetic-openapi-id", + "name":"Synthetic Display", + "nick_name":"Synthetic Nick", + "username":"synthetic-login", + "avatar":"https://qoder.example/avatar", + "email":"synthetic@example.invalid", + "organization_id":"synthetic-org", + "organization_name":"Synthetic Org", + "is_privacy_policy_modifiable":false + }`)) + })) + defer server.Close() + + manager := &authManager{ + openapiBase: server.URL, + httpc: server.Client(), + logf: func(string, ...any) {}, + } + profile, err := manager.requestUserInfo(context.Background(), "synthetic-token") + if err != nil { + t.Fatalf("requestUserInfo() error = %v", err) + } + if profile.UID != "synthetic-openapi-id" { + t.Fatalf("profile UID = %q, want current-shape id", profile.UID) + } + if profile.Name != "Synthetic Display" { + t.Fatalf("profile Name = %q, want name", profile.Name) + } + if profile.OrgID != "synthetic-org" || profile.OrgName != "Synthetic Org" { + t.Fatalf("profile organization = %q/%q, want synthetic-org/Synthetic Org", profile.OrgID, profile.OrgName) + } + if profile.AvatarURL != "https://qoder.example/avatar" { + t.Fatalf("profile AvatarURL = %q, want avatar fallback", profile.AvatarURL) + } + if profile.IsDataPolicyModifiable == nil || *profile.IsDataPolicyModifiable { + t.Fatal("profile privacy-policy flag was not normalized to false") + } + + ui := &userInfo{} + applyAuthUserInfoProfile(ui, profile) + if ui.UID != "synthetic-openapi-id" || ui.OrgID != "synthetic-org" || ui.AvatarURL != "https://qoder.example/avatar" { + t.Fatalf("applied profile = %+v", ui) + } + if ui.IsDataPolicyModifiable { + t.Fatal("applied privacy-policy flag = true, want false") + } +} + +func TestAuthUserInfoProfileKeepsLegacyShape(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api/v1/userinfo" { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{ + "uid":"synthetic-legacy-uid", + "name":"Legacy Name", + "email":"legacy@example.invalid", + "avatar_url":"https://qoder.example/legacy-avatar", + "organization_id":"legacy-org", + "organization_name":"Legacy Org", + "organization_tags":["legacy-tag"], + "data_policy_agreed":true, + "is_data_policy_modifiable":true + }`)) + })) + defer server.Close() + + manager := &authManager{ + openapiBase: server.URL, + httpc: server.Client(), + logf: func(string, ...any) {}, + } + profile, err := manager.requestUserInfo(context.Background(), "synthetic-token") + if err != nil { + t.Fatalf("requestUserInfo() error = %v", err) + } + if profile.UID != "synthetic-legacy-uid" || profile.OrgID != "legacy-org" { + t.Fatalf("legacy profile = %+v", profile) + } + if profile.DataPolicyAgreed == nil || !*profile.DataPolicyAgreed { + t.Fatal("legacy data_policy_agreed was not decoded") + } + if profile.IsDataPolicyModifiable == nil || !*profile.IsDataPolicyModifiable { + t.Fatal("legacy is_data_policy_modifiable was not decoded") + } + if len(profile.OrgTags) != 1 || profile.OrgTags[0] != "legacy-tag" { + t.Fatalf("legacy organization tags = %v", profile.OrgTags) + } +} + +func TestAuthManagerPATLoginAcceptsCurrentGlobalUserInfoShape(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch r.URL.Path { + case "/api/v1/jobToken/exchange": + _, _ = w.Write([]byte(`{"token":"synthetic-current-pat-token","refresh_token":"synthetic-current-pat-refresh","expires_in":7200}`)) + case "/api/v1/userinfo": + _, _ = w.Write([]byte(`{ + "id":"synthetic-current-pat-user", + "name":"Synthetic Current User", + "username":"synthetic-current-login", + "avatar":"https://qoder.example/current-avatar", + "organization_id":"synthetic-current-org", + "organization_name":"Synthetic Current Org", + "is_privacy_policy_modifiable":false + }`)) + default: + http.NotFound(w, r) + } + })) + defer server.Close() + + generator := &fakeRuntimeFieldGenerator{output: runtimeFieldOutput{EncryptUserInfo: "synthetic-runtime-field", Key: "synthetic-runtime-key"}} + factory := &fakeProtocolContextFactory{contexts: []protocolContext{&fakeProtocolContext{}}} + codec := &fakeCredentialCodec{encrypted: "synthetic-encrypted-credential"} + manager := syntheticAuthManager(t, &authManager{ + protocol: &protocolServices{ + credentials: codec, + runtimeFields: generator, + contextFactory: factory, + }, + authFile: filepath.Join(t.TempDir(), "user"), + machineID: "0123456789abcdef-extra", + openapiBase: server.URL, + httpc: server.Client(), + logf: func(string, ...any) {}, + }) + + if err := patLogin(context.Background(), manager, "synthetic-pat"); err != nil { + t.Fatalf("patLogin() error = %v", err) + } + if manager.ui == nil { + t.Fatal("PAT login did not commit a user profile") + } + if manager.ui.UID != "synthetic-current-pat-user" { + t.Fatalf("committed UID = %q, want current-shape id", manager.ui.UID) + } + if manager.ui.Name != "Synthetic Current User" || manager.ui.OrgID != "synthetic-current-org" { + t.Fatalf("committed profile = %+v", manager.ui) + } + if generator.calls != 1 || len(factory.configs) != 1 { + t.Fatalf("runtime field/context calls = %d/%d, want 1/1", generator.calls, len(factory.configs)) + } +} diff --git a/main.go b/main.go index 9f070e1..70f4680 100644 --- a/main.go +++ b/main.go @@ -438,10 +438,10 @@ func (am *authManager) requestPATCandidate(ctx context.Context, pat string, prev if isAuthConflict(err) { return nil, err } - return nil, fmt.Errorf("could not fetch user info after exchange") + return nil, fmt.Errorf("could not fetch user info after exchange: %w", err) } if profile.UID == "" { - return nil, fmt.Errorf("could not fetch user info after exchange") + return nil, fmt.Errorf("could not fetch user info after exchange: userinfo response missing user id") } applyAuthUserInfoProfile(candidate, profile) return candidate, nil