Skip to content
Closed
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
99 changes: 91 additions & 8 deletions auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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
}

Expand All @@ -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
}
Expand Down
164 changes: 164 additions & 0 deletions auth_userinfo_shape_test.go
Original file line number Diff line number Diff line change
@@ -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))
}
}
4 changes: 2 additions & 2 deletions main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down