-
Notifications
You must be signed in to change notification settings - Fork 32
Identify each CLI install to HEY with its own install_id #355
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,111 @@ | ||
| package auth | ||
|
|
||
| import ( | ||
| "crypto/rand" | ||
| "fmt" | ||
| "os" | ||
| "path/filepath" | ||
| "regexp" | ||
| "strings" | ||
| ) | ||
|
|
||
| // InstallID identifies this install to HEY as a device, minting the identifier on first | ||
| // use. It lives beside the credentials rather than in them: a device outlasts a logout, | ||
| // and HEY alerts on a sign-in from a device it hasn't seen. | ||
| func (s *Store) InstallID() (string, error) { | ||
| unlock, err := s.lock() | ||
| if err != nil { | ||
| return "", err | ||
| } | ||
| defer unlock() | ||
|
|
||
| return s.installID() | ||
| } | ||
|
|
||
| // installID is the unlocked variant, for a caller already holding the store lock. | ||
| func (s *Store) installID() (string, error) { | ||
| path := s.installIDPath() | ||
|
|
||
| data, err := os.ReadFile(path) //nolint:gosec // G304: path built from the store's own config directory | ||
| if err == nil { | ||
| // Only a well-formed identifier is a usable identity. A truncated or | ||
| // garbage file — an earlier write interrupted by a crash or a full | ||
| // disk, say — must not be adopted and sent to HEY on every login and | ||
| // refresh, so fall through and mint a fresh one over it. | ||
| if id := strings.TrimSpace(string(data)); isInstallID(id) { | ||
| return id, nil | ||
| } | ||
| } else if !os.IsNotExist(err) { | ||
| return "", err | ||
| } | ||
|
|
||
| id := newInstallID() | ||
| if err := os.MkdirAll(s.fallbackDir, 0700); err != nil { | ||
| return "", err | ||
| } | ||
| if err := writeFileAtomic(path, []byte(id+"\n"), 0600); err != nil { | ||
| return "", err | ||
| } | ||
| return id, nil | ||
| } | ||
|
|
||
| // installIDPattern is the canonical version-4 UUID shape newInstallID mints and | ||
| // the mobile apps send. | ||
| var installIDPattern = regexp.MustCompile(`^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$`) | ||
|
|
||
| func isInstallID(id string) bool { | ||
| return installIDPattern.MatchString(id) | ||
| } | ||
|
|
||
| // writeFileAtomic writes data to a temporary mode-perm file in the destination | ||
| // directory and renames it into place. A crash or full disk mid-write then | ||
| // leaves the previous file (or none) rather than a truncated one that the next | ||
| // run would mistake for a valid identifier. | ||
| func writeFileAtomic(path string, data []byte, perm os.FileMode) error { | ||
| tmp, err := os.CreateTemp(filepath.Dir(path), ".install_id-*") | ||
| if err != nil { | ||
| return err | ||
| } | ||
| tmpName := tmp.Name() | ||
| defer func() { | ||
| if tmpName != "" { | ||
| _ = os.Remove(tmpName) | ||
| } | ||
| }() | ||
|
|
||
| if err := tmp.Chmod(perm); err != nil { | ||
| _ = tmp.Close() | ||
| return err | ||
| } | ||
| if _, err := tmp.Write(data); err != nil { | ||
| _ = tmp.Close() | ||
| return err | ||
| } | ||
| if err := tmp.Sync(); err != nil { | ||
| _ = tmp.Close() | ||
| return err | ||
| } | ||
| if err := tmp.Close(); err != nil { | ||
| return err | ||
| } | ||
| if err := os.Rename(tmpName, path); err != nil { | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
On Windows, which this repository ships, Go explicitly does not guarantee that Useful? React with 👍 / 👎. |
||
| return err | ||
| } | ||
| tmpName = "" // renamed into place; nothing to clean up | ||
| return nil | ||
| } | ||
|
|
||
| func (s *Store) installIDPath() string { | ||
| return filepath.Join(s.fallbackDir, "install_id") | ||
| } | ||
|
|
||
| // newInstallID is a random version-4 UUID, the shape the mobile apps send. | ||
| func newInstallID() string { | ||
| b := make([]byte, 16) | ||
| if _, err := rand.Read(b); err != nil { | ||
| panic("crypto/rand failed: " + err.Error()) | ||
| } | ||
| b[6] = (b[6] & 0x0f) | 0x40 | ||
| b[8] = (b[8] & 0x3f) | 0x80 | ||
| return fmt.Sprintf("%x-%x-%x-%x-%x", b[0:4], b[4:6], b[6:8], b[8:10], b[10:16]) | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,101 @@ | ||
| package auth | ||
|
|
||
| import ( | ||
| "os" | ||
| "path/filepath" | ||
| "regexp" | ||
| "testing" | ||
| ) | ||
|
|
||
| var uuidV4 = regexp.MustCompile(`^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$`) | ||
|
|
||
| func TestInstallIDIsMintedOnceAndPersists(t *testing.T) { | ||
| t.Setenv("HEY_NO_KEYRING", "1") | ||
| configDir := t.TempDir() | ||
| store := NewStore(configDir) | ||
|
|
||
| first, err := store.InstallID() | ||
| if err != nil { | ||
| t.Fatalf("InstallID: %v", err) | ||
| } | ||
| if !uuidV4.MatchString(first) { | ||
| t.Fatalf("install id = %q, want a v4 UUID", first) | ||
| } | ||
|
|
||
| second, err := store.InstallID() | ||
| if err != nil { | ||
| t.Fatalf("InstallID: %v", err) | ||
| } | ||
| if second != first { | ||
| t.Errorf("install id changed between calls: %q then %q", first, second) | ||
| } | ||
|
|
||
| if other, _ := NewStore(configDir).InstallID(); other != first { | ||
| t.Errorf("install id = %q from a second store on the same directory, want %q", other, first) | ||
| } | ||
|
|
||
| info, err := os.Stat(filepath.Join(configDir, "install_id")) | ||
| if err != nil { | ||
| t.Fatalf("Stat: %v", err) | ||
| } | ||
| if perm := info.Mode().Perm(); perm != 0600 { | ||
| t.Errorf("install_id mode = %o, want 0600", perm) | ||
| } | ||
| } | ||
|
|
||
| func TestInstallIDSurvivesLogout(t *testing.T) { | ||
| t.Setenv("HEY_NO_KEYRING", "1") | ||
| configDir := t.TempDir() | ||
| mgr := NewManager("https://app.hey.com", nil, configDir) | ||
|
|
||
| id, err := mgr.GetStore().InstallID() | ||
| if err != nil { | ||
| t.Fatalf("InstallID: %v", err) | ||
| } | ||
| if err := mgr.LoginWithToken("token"); err != nil { | ||
| t.Fatalf("LoginWithToken: %v", err) | ||
| } | ||
| if err := mgr.Logout(); err != nil { | ||
| t.Fatalf("Logout: %v", err) | ||
| } | ||
|
|
||
| if after, _ := mgr.GetStore().InstallID(); after != id { | ||
| t.Errorf("install id = %q after logout, want %q", after, id) | ||
| } | ||
| } | ||
|
|
||
| func TestInstallIDsDifferPerInstall(t *testing.T) { | ||
| t.Setenv("HEY_NO_KEYRING", "1") | ||
| a, _ := NewStore(t.TempDir()).InstallID() | ||
| b, _ := NewStore(t.TempDir()).InstallID() | ||
| if a == b { | ||
| t.Errorf("two installs share install id %q", a) | ||
| } | ||
| } | ||
|
|
||
| func TestInstallIDReplacesAMalformedFile(t *testing.T) { | ||
| t.Setenv("HEY_NO_KEYRING", "1") | ||
| configDir := t.TempDir() | ||
| path := filepath.Join(configDir, "install_id") | ||
|
|
||
| // A truncated or garbage file — e.g. a write interrupted by a crash or a | ||
| // full disk, or the old constant "hey-cli" identifier — is not a usable | ||
| // identity and must be reminted, never sent to HEY as-is. | ||
| if err := os.WriteFile(path, []byte("hey-cli"), 0600); err != nil { | ||
| t.Fatalf("seed: %v", err) | ||
| } | ||
|
|
||
| id, err := NewStore(configDir).InstallID() | ||
| if err != nil { | ||
| t.Fatalf("InstallID: %v", err) | ||
| } | ||
| if !uuidV4.MatchString(id) { | ||
| t.Fatalf("install id = %q, want a v4 UUID", id) | ||
| } | ||
|
|
||
| // The mint is durable: the replacement is written back, so a second store | ||
| // reads the same value rather than reminting again. | ||
| if again, _ := NewStore(configDir).InstallID(); again != id { | ||
| t.Errorf("install id = %q on reload, want the reminted %q", again, id) | ||
| } | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
For users upgrading from the old password-grant CLI who supplied its supported
--install-idoption,migrateOldCredentialsmigrates the tokens butOldConfigdoes not read the existinginstall_id, andScrubLegacyCredentialsthen deletes it. This path consequently mints a different UUID here before the next refresh; unlike the documented placeholder-to-real transition, that changes an already-custom device identity and can make a refresh-token lineage bound to the old ID fail. Migrate a valid legacy installation ID into this file before scrubbing the old config.Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Not doing this — leaving it for a human call. The premise is partly right:
install_idis inlegacyCredentialKeys, so the old config.json can carry one. ButOldConfigdeliberately omitsinstall_idtogether withclient_id/client_secret— the redesign does not carry the old password-grant client identity forward at all. Re-adopting only the legacy install_id while still dropping the client it was minted under would be incoherent: a password-grant refresh token is not refreshable by the new OAuth client regardless of install_id, so install_id is not the load-bearing factor in that lineage. A fresh device identity (and HEYs new-device signal) on the first OAuth login after upgrade is the intended behavior of this change, not a regression to paper over. It also reaches into migration code (OldConfig,migrateOldCredentials) outside this PRs diff. Flagging for a human decision rather than acting on it.