Movatterモバイル変換


[0]ホーム

URL:


Skip to content

Navigation Menu

Sign in
Appearance settings

Search code, repositories, users, issues, pull requests...

Provide feedback

We read every piece of feedback, and take your input very seriously.

Saved searches

Use saved searches to filter your results more quickly

Sign up
Appearance settings

feat(cli): add macOS support for session token keyring storage#20613

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 ourterms of service andprivacy statement. We’ll occasionally send you account related emails.

Already on GitHub?Sign in to your account

Draft
zedkipp wants to merge1 commit intomain
base:main
Choose a base branch
Loading
fromzedkipp/keyring-macos
Draft
Show file tree
Hide file tree
Changes fromall 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
6 changes: 3 additions & 3 deletionscli/keyring_test.go
View file
Open in desktop
Original file line numberDiff line numberDiff line change
Expand Up@@ -282,9 +282,9 @@ func TestUseKeyringUnsupportedOS(t *testing.T) {
// a helpful error message.
t.Parallel()

//Skip on Windows since the keyring is actually supported.
if runtime.GOOS == "windows" {
t.Skip("Skipping unsupported OS test onWindows where keyring is supported")
//Only run this on an unsupported OS.
if runtime.GOOS == "windows"|| runtime.GOOS == "darwin"{
t.Skipf("Skipping unsupported OS test on%s where keyring is supported", runtime.GOOS)
}

const expMessage = "keyring storage is not supported on this operating system; remove the --use-keyring flag"
Expand Down
109 changes: 109 additions & 0 deletionscli/sessionstore/sessionstore_darwin.go
View file
Open in desktop
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
//go:build darwin

package sessionstore

import (
"encoding/base64"
"fmt"
"io"
"os"
"os/exec"
"regexp"
"strings"
)

const (
// defaultServiceName is the service name used in the macOS Keychain
// for storing Coder CLI session tokens.
defaultServiceName="coder-v2-credentials"

// fixedUsername is the fixed username used for all keychain entries.
// Since our interface only uses service names, we use a constant username.
fixedUsername="coder-session-token"

execPathKeychain="/usr/bin/security"
notFoundStr="could not be found"
)

// operatingSystemKeyring implements keyringProvider for macOS.
// It is largely adapted from the zalando/go-keyring package.
typeoperatingSystemKeyringstruct{}

func (operatingSystemKeyring)Set(service,credentialstring)error {
// if the added secret has multiple lines or some non ascii,
// macOS will hex encode it on return. To avoid getting garbage, we
// encode all passwords
Copy link
ContributorAuthor

Choose a reason for hiding this comment

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

@ethanndickson what are your thoughts on keeping this base64 encoding/decoding? It's borrowed fromhttps://github.com/zalando/go-keyring/. I don't think we explicitly need it given our token format.

password:=base64.StdEncoding.EncodeToString([]byte(credential))

cmd:=exec.Command(execPathKeychain,"-i")
stdIn,err:=cmd.StdinPipe()
iferr!=nil {
returnerr
}

iferr=cmd.Start();err!=nil {
returnerr
}

command:=fmt.Sprintf("add-generic-password -U -s %s -a %s -w %s\n",
shellEscape(service),
shellEscape(fixedUsername),
shellEscape(password))
iflen(command)>4096 {
returnErrSetDataTooBig
}

if_,err:=io.WriteString(stdIn,command);err!=nil {
returnerr
}

iferr=stdIn.Close();err!=nil {
returnerr
}

returncmd.Wait()
}

func (operatingSystemKeyring)Get(servicestring) ([]byte,error) {
out,err:=exec.Command(
execPathKeychain,
"find-generic-password",
"-s",service,
"-wa",fixedUsername).CombinedOutput()
iferr!=nil {
ifstrings.Contains(string(out),notFoundStr) {
returnnil,os.ErrNotExist
}
returnnil,err
}

trimStr:=strings.TrimSpace(string(out))
returnbase64.StdEncoding.DecodeString(trimStr)
}

func (operatingSystemKeyring)Delete(servicestring)error {
out,err:=exec.Command(
execPathKeychain,
"delete-generic-password",
"-s",service,
"-a",fixedUsername).CombinedOutput()
ifstrings.Contains(string(out),notFoundStr) {
returnos.ErrNotExist
}
returnerr
}

// shellEscape returns a shell-escaped version of the string s.
// This is adapted from github.com/zalando/go-keyring/internal/shellescape.
funcshellEscape(sstring)string {
iflen(s)==0 {
return"''"
}

pattern:=regexp.MustCompile(`[^\w@%+=:,./-]`)
ifpattern.MatchString(s) {
return"'"+strings.ReplaceAll(s,"'","'\"'\"'")+"'"
}

returns
}
34 changes: 34 additions & 0 deletionscli/sessionstore/sessionstore_darwin_test.go
View file
Open in desktop
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
//go:build darwin

package sessionstore_test

import (
"encoding/base64"
"os/exec"
"testing"
)

const (
execPathKeychain = "/usr/bin/security"
fixedUsername = "coder-session-token"
)

func readRawKeychainCredential(t *testing.T, service string) []byte {
t.Helper()

out, err := exec.Command(
execPathKeychain,
"find-generic-password",
"-s", service,
"-wa", fixedUsername).CombinedOutput()
if err != nil {
t.Fatal(err)
}

dst := make([]byte, base64.StdEncoding.DecodedLen(len(out)))
n, err := base64.StdEncoding.Decode(dst, out)
if err != nil {
t.Fatal(err)
}
return dst[:n]
}
2 changes: 1 addition & 1 deletioncli/sessionstore/sessionstore_other.go
View file
Open in desktop
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
//go:build !windows
//go:build !windows && !darwin

package sessionstore

Expand Down
10 changes: 10 additions & 0 deletionscli/sessionstore/sessionstore_other_test.go
View file
Open in desktop
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
//go:build !windows && !darwin

package sessionstore_test

import "testing"

func readRawKeychainCredential(t *testing.T, _ string) []byte {
t.Fatal("not implemented")
return nil
}
70 changes: 68 additions & 2 deletionscli/sessionstore/sessionstore_test.go
View file
Open in desktop
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
package sessionstore_test

import (
"encoding/json"
"errors"
"fmt"
"net/url"
Expand All@@ -16,6 +17,11 @@ import (
"github.com/coder/coder/v2/cli/sessionstore"
)

type storedCredentials map[string]struct {
CoderURL string `json:"coder_url"`
APIToken string `json:"api_token"`
}
Copy link
ContributorAuthor

@zedkippzedkippOct 31, 2025
edited
Loading

Choose a reason for hiding this comment

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

@ethanndickson this is essentially what gets stored as a JSON blob (b64 encoded -see my other comment). Looks like this in my keychain. Any problems/concerns for Coder Desktop on macOS, in terms of future session token sharing?
Screenshot 2025-10-31 at 4 27 17 PM


// Generate a test service name for use with the OS keyring. It uses a combination
// of the test name and a nanosecond timestamp to prevent collisions.
func keyringTestServiceName(t *testing.T) string {
Expand All@@ -26,8 +32,8 @@ func keyringTestServiceName(t *testing.T) string {
func TestKeyring(t *testing.T) {
t.Parallel()

if runtime.GOOS != "windows" {
t.Skip("linuxand darwin are not supported yet")
if runtime.GOOS != "windows"&& runtime.GOOS != "darwin"{
t.Skip("linuxis not supported yet")
}

// This test exercises use of the operating system keyring. As a result,
Expand DownExpand Up@@ -199,6 +205,66 @@ func TestKeyring(t *testing.T) {
err = backend.Delete(srvURL2)
require.NoError(t, err)
})

t.Run("StorageFormat", func(t *testing.T) {
t.Parallel()
// The storage format must remain consistent to ensure we don't break
// compatibility with other Coder related applications that may read
// or decode the same credential.

const testURL1 = "http://127.0.0.1:1337"
srv1URL, err := url.Parse(testURL1)
require.NoError(t, err)

const testURL2 = "http://127.0.0.1:1338"
srv2URL, err := url.Parse(testURL2)
require.NoError(t, err)

serviceName := keyringTestServiceName(t)
backend := sessionstore.NewKeyringWithService(serviceName)
t.Cleanup(func() {
_ = backend.Delete(srv1URL)
_ = backend.Delete(srv2URL)
})

// Write token for server 1
const token1 = "token-server-1"
err = backend.Write(srv1URL, token1)
require.NoError(t, err)

// Write token for server 2 (should NOT overwrite server 1's token)
const token2 = "token-server-2"
err = backend.Write(srv2URL, token2)
require.NoError(t, err)

// Verify both credentials are stored in the raw format and can
// be extracted through the Backend API.
rawCredential := readRawKeychainCredential(t, serviceName)

storedCreds := make(storedCredentials)
err = json.Unmarshal(rawCredential, &storedCreds)
require.NoError(t, err, "unmarshalling stored credentials")

// Both credentials should exist
require.Len(t, storedCreds, 2)
require.Equal(t, token1, storedCreds[srv1URL.Host].APIToken)
require.Equal(t, token2, storedCreds[srv2URL.Host].APIToken)

// Read individual credentials
token, err := backend.Read(srv1URL)
require.NoError(t, err)
require.Equal(t, token1, token)

token, err = backend.Read(srv2URL)
require.NoError(t, err)
require.Equal(t, token2, token)

// Cleanup
err = backend.Delete(srv1URL)
require.NoError(t, err)
err = backend.Delete(srv2URL)
require.NoError(t, err)
})
}

func TestFile(t *testing.T) {
Expand Down
75 changes: 11 additions & 64 deletionscli/sessionstore/sessionstore_windows_test.go
View file
Open in desktop
Original file line numberDiff line numberDiff line change
Expand Up@@ -14,6 +14,16 @@ import (
"github.com/coder/coder/v2/cli/sessionstore"
)

func readRawKeychainCredential(t *testing.T, serviceName string) []byte {
t.Helper()

winCred, err := wincred.GetGenericCredential(serviceName)
if err != nil {
t.Fatal(err)
}
return winCred.CredentialBlob
}

func TestWindowsKeyring_WriteReadDelete(t *testing.T) {
t.Parallel()

Expand All@@ -38,10 +48,7 @@ func TestWindowsKeyring_WriteReadDelete(t *testing.T) {
winCred, err := wincred.GetGenericCredential(serviceName)
require.NoError(t, err, "getting windows credential")

var storedCreds map[string]struct {
CoderURL string `json:"coder_url"`
APIToken string `json:"api_token"`
}
storedCreds := make(storedCredentials)
err = json.Unmarshal(winCred.CredentialBlob, &storedCreds)
require.NoError(t, err, "unmarshalling stored credentials")

Expand All@@ -65,63 +72,3 @@ func TestWindowsKeyring_WriteReadDelete(t *testing.T) {
_, err = backend.Read(srvURL)
require.ErrorIs(t, err, os.ErrNotExist)
}

func TestWindowsKeyring_MultipleServers(t *testing.T) {
t.Parallel()

const testURL1 = "http://127.0.0.1:1337"
srv1URL, err := url.Parse(testURL1)
require.NoError(t, err)

const testURL2 = "http://127.0.0.1:1338"
srv2URL, err := url.Parse(testURL2)
require.NoError(t, err)

serviceName := keyringTestServiceName(t)
backend := sessionstore.NewKeyringWithService(serviceName)
t.Cleanup(func() {
_ = backend.Delete(srv1URL)
_ = backend.Delete(srv2URL)
})

// Write token for server 1
const token1 = "token-server-1"
err = backend.Write(srv1URL, token1)
require.NoError(t, err)

// Write token for server 2 (should NOT overwrite server 1's token)
const token2 = "token-server-2"
err = backend.Write(srv2URL, token2)
require.NoError(t, err)

// Verify both credentials are stored in Windows Credential Manager
winCred, err := wincred.GetGenericCredential(serviceName)
require.NoError(t, err, "getting windows credential")

var storedCreds map[string]struct {
CoderURL string `json:"coder_url"`
APIToken string `json:"api_token"`
}
err = json.Unmarshal(winCred.CredentialBlob, &storedCreds)
require.NoError(t, err, "unmarshalling stored credentials")

// Both credentials should exist
require.Len(t, storedCreds, 2)
require.Equal(t, token1, storedCreds[srv1URL.Host].APIToken)
require.Equal(t, token2, storedCreds[srv2URL.Host].APIToken)

// Read individual credentials
token, err := backend.Read(srv1URL)
require.NoError(t, err)
require.Equal(t, token1, token)

token, err = backend.Read(srv2URL)
require.NoError(t, err)
require.Equal(t, token2, token)

// Cleanup
err = backend.Delete(srv1URL)
require.NoError(t, err)
err = backend.Delete(srv2URL)
require.NoError(t, err)
}
Loading

[8]ページ先頭

©2009-2025 Movatter.jp