Skip to content

feat: Add ListUsers method to retrieve usernames for a service (#133)#135

Open
ayoub3bidi wants to merge 2 commits intozalando:masterfrom
ayoub3bidi:feat/list-user-names-by-service
Open

feat: Add ListUsers method to retrieve usernames for a service (#133)#135
ayoub3bidi wants to merge 2 commits intozalando:masterfrom
ayoub3bidi:feat/list-user-names-by-service

Conversation

@ayoub3bidi
Copy link

Implements a new ListUsers(service string) ([]string, error) method that retrieves all usernames associated with a given service without exposing the secret values. This addresses the feature request to allow CLI tools and applications to discover which credentials are stored for a service.

Motivation

As described in #133, when using go-keyring in CLI tools that manage multiple third-party tokens (e.g., GitHub, AWS, etc.), users need a way to see which secrets the tool is managing without manually checking their system keychain. This feature enables better UX by allowing applications to list available credentials.

Example use case: A CLI tool with service name my-cli that manages tokens for multiple providers (GitHub, GitLab, AWS). The tool can now call ListUsers("my-cli") to show users which providers they have configured.

Changes

Core Interface

  • keyring.go: Added ListUsers(service string) ([]string, error) to Keyring interface
  • keyring.go: Added package-level ListUsers function

Platform Implementations

macOS (keyring_darwin.go)

  • Parses security dump-keyring output to extract account names
  • Filters by service name and deduplicates results
  • Returns empty slice for non-existent services

Linux/Unix (keyring_unix.go)

  • Leverages existing findServiceItems helper to query D-Bus Secret Service
  • Retrieves username attributes from items
  • Added helper method GetItemAttributes to secret_service/secret_service.go
  • Gracefully handles missing services (returns empty slice)

Windows (keyring_windows.go)

  • Uses wincred.List() to enumerate credentials
  • Filters by service prefix and extracts usernames
  • Consistent with DeleteAll implementation pattern

Mock & Fallback

Tests

Added four comprehensive test cases to keyring_test.go:

  • TestListUsers: Multiple users per service
  • TestListUsersEmpty: Non-existent service returns empty slice
  • TestListUsersSingleUser: Single user edge case
  • TestListUsersMultipleServices: Service isolation verification

Test Results

$ go test -v
=== RUN   TestListUsers
--- PASS: TestListUsers (0.05s)
=== RUN   TestListUsersEmpty
--- PASS: TestListUsersEmpty (0.00s)
=== RUN   TestListUsersSingleUser
--- PASS: TestListUsersSingleUser (0.02s)
=== RUN   TestListUsersMultipleServices
--- PASS: TestListUsersMultipleServices (0.05s)
PASS
ok      github.com/zalando/go-keyring   0.270s
  • All 23 tests passing (4 new + 19 existing)
  • No regressions
  • Build successful

Usage Example

package main

import (
    "fmt"
    "log"
    
    "github.com/zalando/go-keyring"
)

func main() {
    service := "my-cli"
    
    // Store some credentials
    keyring.Set(service, "github", "ghp_token123")
    keyring.Set(service, "gitlab", "glpat_token456")
    keyring.Set(service, "aws", "aws_secret789")
    
    // List all configured providers
    users, err := keyring.ListUsers(service)
    if err != nil {
        log.Fatal(err)
    }
    
    fmt.Println("Configured providers:")
    for _, user := range users {
        fmt.Printf("  - %s\n", user)
    }
    // Output:
    // Configured providers:
    //   - github
    //   - gitlab
    //   - aws
}

Backward Compatibility

No breaking changes

  • Purely additive API change
  • All existing code continues to work without modification
  • Optional feature that doesn't affect current users

Implementation Notes

Naming Decision

As suggested by @szuecs in #133, the method is named ListUsers instead of List for clarity and to avoid ambiguity about what is being listed.

Platform-Specific Behavior

  • Empty service: All implementations return empty slice (not error)
  • Non-existent service: Returns empty slice consistently across platforms
  • Order: Usernames returned in arbitrary order (platform-dependent)
  • Deduplication: All implementations deduplicate usernames using maps

Security

  • Returns only usernames, never secret values
  • Follows existing platform security patterns
  • Respects keychain/credential manager locking mechanisms

Checklist

  • Implementation complete for all platforms
  • Tests added and passing
  • No regressions in existing tests
  • Build successful
  • Documentation updated
  • Backward compatible

…do#133)

Signed-off-by: Ayoub Abidi <mrayoubabidi@gmail.com>
@ayoub3bidi ayoub3bidi force-pushed the feat/list-user-names-by-service branch from 51a2f58 to ac1ec0c Compare January 17, 2026 13:00
return []string{}, nil
}

out, err := exec.Command(execPathKeychain, "dump-keyring").CombinedOutput()

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

/usr/bin/security dump-keyring
security: unknown command "dump-keyring"

Copy link

@AlexanderYastrebov AlexanderYastrebov Mar 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

package main

import (
	"bufio"
	"bytes"
	"fmt"
	"io"
	"os"
	"os/exec"
	"strings"
)

type entry struct {
	keychain string
	svce     string
	acct     string
}

func main() {
	service := os.Args[1]

	b, err := exec.Command("/usr/bin/security", "default-keychain").Output()
	if err != nil {
		panic(err)
	}
	defaultKeychain := strings.Trim(string(b), " \"\n")

	b, err = exec.Command("/usr/bin/security", "dump-keychain").Output()
	if err != nil {
		panic(err)
	}

	var entries []*entry
	var e *entry
	valueOf := func(s, prefix string) string {
		return strings.Trim(strings.TrimPrefix(s, prefix), "\"\n")
	}

	r := bufio.NewReader(bytes.NewReader(b))
	for {
		line, err := r.ReadString('\n')
		if err != nil && err != io.EOF {
			panic(err)
		}
		switch {
		case strings.HasPrefix(line, `keychain: `):
			e = new(entry)
			e.keychain = valueOf(line, `keychain: `)
			entries = append(entries, e)
		case strings.HasPrefix(line, `    "svce"<blob>=`):
			e.svce = valueOf(line, `    "svce"<blob>=`)
		case strings.HasPrefix(line, `    "acct"<blob>=`):
			e.acct = valueOf(line, `    "acct"<blob>=`)
		}
		if err == io.EOF {
			break
		}
	}

	for _, e := range entries {
		if e.keychain == defaultKeychain && e.svce == service {
			fmt.Printf("%s\n", e.acct)
		}
	}
}

lines := strings.Split(string(out), "\n")

// Parse dump-keyring output looking for generic passwords matching our service
// Format: keychain: "/Users/username/Library/Keychains/login.keychain-db"

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There are multiple keychains so it should probably call /usr/bin/security default-keychain to get the name of the default one.

@szuecs
Copy link
Member

szuecs commented Mar 6, 2026

@ayoub3bidi thanks for the PR for me looks like a nice addition.
I would like that the Example you specified here could be an Example that is tested in Go. The Example will be rendered in go docs and it runs by the test suite to make sure the code produces the output. I don't know if it's easily possible but I guess you can a mock implementation.
Last @AlexanderYastrebov has a good review. Let's try to fix the findings.

@szuecs szuecs added the major moderate risk, for example new API, small changes that have no risk label Mar 6, 2026
@ayoub3bidi
Copy link
Author

@AlexanderYastrebov @szuecs thanks for the review. I've addressed the findings:

  1. dump-keyring → dump-keychain: Fixed the typo. The correct command is dump-keychain, which requires a keychain path.

  2. Multiple keychains: The code now calls security default-keychain to get the default keychain path, passes it to dump-keychain, and only includes entries from that keychain when parsing.

  3. Testable Example: Added ExampleListUsers() that uses MockInit() so it runs without a system keyring. It's part of the test suite (verified by go test) and will show up in godoc. Also introduced MockRestore() to reset the provider after the example.

Happy to adjust if you have further feedback.

@szuecs
Copy link
Member

szuecs commented Mar 9, 2026

@ayoub3bidi you need to sign-off your commits (DCO check)

- Use dump-keychain instead of dump-keyring
- Filter by default keychain when multiple exist
- Add ExampleListUsers with MockInit for godoc
- Add MockRestore for provider restoration

Signed-off-by: Ayoub Abidi <mrayoubabidi@gmail.com>
@ayoub3bidi ayoub3bidi force-pushed the feat/list-user-names-by-service branch from 1da1493 to 24cf490 Compare March 14, 2026 08:53
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

major moderate risk, for example new API, small changes that have no risk

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants