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