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

Commit5e85663

Browse files
authored
feat(cli): add macOS support for session token keyring storage (#20613)
Add support for storing the CLI session token in the OS keyring on macOSwhen the --use-keyring flag is provided.#19403https://www.notion.so/coderhq/CLI-Session-Token-in-OS-Keyring-293d579be592808b8b7fd235304e50d5
1 parentc47b437 commit5e85663

9 files changed

+238
-78
lines changed

‎cli/keyring_test.go‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -282,9 +282,9 @@ func TestUseKeyringUnsupportedOS(t *testing.T) {
282282
// a helpful error message.
283283
t.Parallel()
284284

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

290290
constexpMessage="keyring storage is not supported on this operating system; remove the --use-keyring flag"

‎cli/sessionstore/sessionstore.go‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,12 @@ var (
4646
ErrNotImplemented=xerrors.New("not implemented")
4747
)
4848

49+
const (
50+
// defaultServiceName is the service name used in keyrings for storing Coder CLI session
51+
// tokens.
52+
defaultServiceName="coder-v2-credentials"
53+
)
54+
4955
// keyringProvider represents an operating system keyring. The expectation
5056
// is these methods operate on the user/login keyring.
5157
typekeyringProviderinterface {
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
//go:build darwin
2+
3+
package sessionstore
4+
5+
import (
6+
"encoding/base64"
7+
"fmt"
8+
"io"
9+
"os"
10+
"os/exec"
11+
"regexp"
12+
"strings"
13+
)
14+
15+
const (
16+
// fixedUsername is the fixed username used for all keychain entries.
17+
// Since our interface only uses service names, we use a constant username.
18+
fixedUsername="coder-login-credentials"
19+
20+
execPathKeychain="/usr/bin/security"
21+
notFoundStr="could not be found"
22+
)
23+
24+
// operatingSystemKeyring implements keyringProvider for macOS.
25+
// It is largely adapted from the zalando/go-keyring package.
26+
typeoperatingSystemKeyringstruct{}
27+
28+
func (operatingSystemKeyring)Set(service,credentialstring)error {
29+
// if the added secret has multiple lines or some non ascii,
30+
// macOS will hex encode it on return. To avoid getting garbage, we
31+
// encode all passwords
32+
password:=base64.StdEncoding.EncodeToString([]byte(credential))
33+
34+
cmd:=exec.Command(execPathKeychain,"-i")
35+
stdIn,err:=cmd.StdinPipe()
36+
iferr!=nil {
37+
returnerr
38+
}
39+
40+
iferr=cmd.Start();err!=nil {
41+
returnerr
42+
}
43+
44+
command:=fmt.Sprintf("add-generic-password -U -s %s -a %s -w %s\n",
45+
shellEscape(service),
46+
shellEscape(fixedUsername),
47+
shellEscape(password))
48+
iflen(command)>4096 {
49+
returnErrSetDataTooBig
50+
}
51+
52+
if_,err:=io.WriteString(stdIn,command);err!=nil {
53+
returnerr
54+
}
55+
56+
iferr=stdIn.Close();err!=nil {
57+
returnerr
58+
}
59+
60+
returncmd.Wait()
61+
}
62+
63+
func (operatingSystemKeyring)Get(servicestring) ([]byte,error) {
64+
out,err:=exec.Command(
65+
execPathKeychain,
66+
"find-generic-password",
67+
"-s",service,
68+
"-wa",fixedUsername).CombinedOutput()
69+
iferr!=nil {
70+
ifstrings.Contains(string(out),notFoundStr) {
71+
returnnil,os.ErrNotExist
72+
}
73+
returnnil,err
74+
}
75+
76+
trimStr:=strings.TrimSpace(string(out))
77+
returnbase64.StdEncoding.DecodeString(trimStr)
78+
}
79+
80+
func (operatingSystemKeyring)Delete(servicestring)error {
81+
out,err:=exec.Command(
82+
execPathKeychain,
83+
"delete-generic-password",
84+
"-s",service,
85+
"-a",fixedUsername).CombinedOutput()
86+
ifstrings.Contains(string(out),notFoundStr) {
87+
returnos.ErrNotExist
88+
}
89+
returnerr
90+
}
91+
92+
// shellEscape returns a shell-escaped version of the string s.
93+
// This is adapted from github.com/zalando/go-keyring/internal/shellescape.
94+
funcshellEscape(sstring)string {
95+
iflen(s)==0 {
96+
return"''"
97+
}
98+
99+
pattern:=regexp.MustCompile(`[^\w@%+=:,./-]`)
100+
ifpattern.MatchString(s) {
101+
return"'"+strings.ReplaceAll(s,"'","'\"'\"'")+"'"
102+
}
103+
104+
returns
105+
}
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
//go:build darwin
2+
3+
package sessionstore_test
4+
5+
import (
6+
"encoding/base64"
7+
"os/exec"
8+
"testing"
9+
)
10+
11+
const (
12+
execPathKeychain="/usr/bin/security"
13+
fixedUsername="coder-login-credentials"
14+
)
15+
16+
funcreadRawKeychainCredential(t*testing.T,servicestring) []byte {
17+
t.Helper()
18+
19+
out,err:=exec.Command(
20+
execPathKeychain,
21+
"find-generic-password",
22+
"-s",service,
23+
"-wa",fixedUsername).CombinedOutput()
24+
iferr!=nil {
25+
t.Fatal(err)
26+
}
27+
28+
dst:=make([]byte,base64.StdEncoding.DecodedLen(len(out)))
29+
n,err:=base64.StdEncoding.Decode(dst,out)
30+
iferr!=nil {
31+
t.Fatal(err)
32+
}
33+
returndst[:n]
34+
}

‎cli/sessionstore/sessionstore_other.go‎

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,7 @@
1-
//go:build !windows
1+
//go:build !windows && !darwin
22

33
package sessionstore
44

5-
constdefaultServiceName="not-implemented"
6-
75
typeoperatingSystemKeyringstruct{}
86

97
func (operatingSystemKeyring)Set(_,_string)error {
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
//go:build !windows && !darwin
2+
3+
package sessionstore_test
4+
5+
import"testing"
6+
7+
funcreadRawKeychainCredential(t*testing.T,_string) []byte {
8+
t.Fatal("not implemented")
9+
returnnil
10+
}

‎cli/sessionstore/sessionstore_test.go‎

Lines changed: 68 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package sessionstore_test
22

33
import (
4+
"encoding/json"
45
"errors"
56
"fmt"
67
"net/url"
@@ -16,6 +17,11 @@ import (
1617
"github.com/coder/coder/v2/cli/sessionstore"
1718
)
1819

20+
typestoredCredentialsmap[string]struct {
21+
CoderURLstring`json:"coder_url"`
22+
APITokenstring`json:"api_token"`
23+
}
24+
1925
// Generate a test service name for use with the OS keyring. It uses a combination
2026
// of the test name and a nanosecond timestamp to prevent collisions.
2127
funckeyringTestServiceName(t*testing.T)string {
@@ -26,8 +32,8 @@ func keyringTestServiceName(t *testing.T) string {
2632
funcTestKeyring(t*testing.T) {
2733
t.Parallel()
2834

29-
ifruntime.GOOS!="windows" {
30-
t.Skip("linuxand darwin are not supported yet")
35+
ifruntime.GOOS!="windows"&&runtime.GOOS!="darwin"{
36+
t.Skip("linuxis not supported yet")
3137
}
3238

3339
// This test exercises use of the operating system keyring. As a result,
@@ -199,6 +205,66 @@ func TestKeyring(t *testing.T) {
199205
err=backend.Delete(srvURL2)
200206
require.NoError(t,err)
201207
})
208+
209+
t.Run("StorageFormat",func(t*testing.T) {
210+
t.Parallel()
211+
// The storage format must remain consistent to ensure we don't break
212+
// compatibility with other Coder related applications that may read
213+
// or decode the same credential.
214+
215+
consttestURL1="http://127.0.0.1:1337"
216+
srv1URL,err:=url.Parse(testURL1)
217+
require.NoError(t,err)
218+
219+
consttestURL2="http://127.0.0.1:1338"
220+
srv2URL,err:=url.Parse(testURL2)
221+
require.NoError(t,err)
222+
223+
serviceName:=keyringTestServiceName(t)
224+
backend:=sessionstore.NewKeyringWithService(serviceName)
225+
t.Cleanup(func() {
226+
_=backend.Delete(srv1URL)
227+
_=backend.Delete(srv2URL)
228+
})
229+
230+
// Write token for server 1
231+
consttoken1="token-server-1"
232+
err=backend.Write(srv1URL,token1)
233+
require.NoError(t,err)
234+
235+
// Write token for server 2 (should NOT overwrite server 1's token)
236+
consttoken2="token-server-2"
237+
err=backend.Write(srv2URL,token2)
238+
require.NoError(t,err)
239+
240+
// Verify both credentials are stored in the raw format and can
241+
// be extracted through the Backend API.
242+
rawCredential:=readRawKeychainCredential(t,serviceName)
243+
244+
storedCreds:=make(storedCredentials)
245+
err=json.Unmarshal(rawCredential,&storedCreds)
246+
require.NoError(t,err,"unmarshalling stored credentials")
247+
248+
// Both credentials should exist
249+
require.Len(t,storedCreds,2)
250+
require.Equal(t,token1,storedCreds[srv1URL.Host].APIToken)
251+
require.Equal(t,token2,storedCreds[srv2URL.Host].APIToken)
252+
253+
// Read individual credentials
254+
token,err:=backend.Read(srv1URL)
255+
require.NoError(t,err)
256+
require.Equal(t,token1,token)
257+
258+
token,err=backend.Read(srv2URL)
259+
require.NoError(t,err)
260+
require.Equal(t,token2,token)
261+
262+
// Cleanup
263+
err=backend.Delete(srv1URL)
264+
require.NoError(t,err)
265+
err=backend.Delete(srv2URL)
266+
require.NoError(t,err)
267+
})
202268
}
203269

204270
funcTestFile(t*testing.T) {

‎cli/sessionstore/sessionstore_windows.go‎

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -10,12 +10,6 @@ import (
1010
"github.com/danieljoos/wincred"
1111
)
1212

13-
const (
14-
// defaultServiceName is the service name used in the Windows Credential Manager
15-
// for storing Coder CLI session tokens.
16-
defaultServiceName="coder-v2-credentials"
17-
)
18-
1913
// operatingSystemKeyring implements keyringProvider and uses Windows Credential Manager.
2014
// It is largely adapted from the zalando/go-keyring package.
2115
typeoperatingSystemKeyringstruct{}

‎cli/sessionstore/sessionstore_windows_test.go‎

Lines changed: 11 additions & 64 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,16 @@ import (
1414
"github.com/coder/coder/v2/cli/sessionstore"
1515
)
1616

17+
funcreadRawKeychainCredential(t*testing.T,serviceNamestring) []byte {
18+
t.Helper()
19+
20+
winCred,err:=wincred.GetGenericCredential(serviceName)
21+
iferr!=nil {
22+
t.Fatal(err)
23+
}
24+
returnwinCred.CredentialBlob
25+
}
26+
1727
funcTestWindowsKeyring_WriteReadDelete(t*testing.T) {
1828
t.Parallel()
1929

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

41-
varstoredCredsmap[string]struct {
42-
CoderURLstring`json:"coder_url"`
43-
APITokenstring`json:"api_token"`
44-
}
51+
storedCreds:=make(storedCredentials)
4552
err=json.Unmarshal(winCred.CredentialBlob,&storedCreds)
4653
require.NoError(t,err,"unmarshalling stored credentials")
4754

@@ -65,63 +72,3 @@ func TestWindowsKeyring_WriteReadDelete(t *testing.T) {
6572
_,err=backend.Read(srvURL)
6673
require.ErrorIs(t,err,os.ErrNotExist)
6774
}
68-
69-
funcTestWindowsKeyring_MultipleServers(t*testing.T) {
70-
t.Parallel()
71-
72-
consttestURL1="http://127.0.0.1:1337"
73-
srv1URL,err:=url.Parse(testURL1)
74-
require.NoError(t,err)
75-
76-
consttestURL2="http://127.0.0.1:1338"
77-
srv2URL,err:=url.Parse(testURL2)
78-
require.NoError(t,err)
79-
80-
serviceName:=keyringTestServiceName(t)
81-
backend:=sessionstore.NewKeyringWithService(serviceName)
82-
t.Cleanup(func() {
83-
_=backend.Delete(srv1URL)
84-
_=backend.Delete(srv2URL)
85-
})
86-
87-
// Write token for server 1
88-
consttoken1="token-server-1"
89-
err=backend.Write(srv1URL,token1)
90-
require.NoError(t,err)
91-
92-
// Write token for server 2 (should NOT overwrite server 1's token)
93-
consttoken2="token-server-2"
94-
err=backend.Write(srv2URL,token2)
95-
require.NoError(t,err)
96-
97-
// Verify both credentials are stored in Windows Credential Manager
98-
winCred,err:=wincred.GetGenericCredential(serviceName)
99-
require.NoError(t,err,"getting windows credential")
100-
101-
varstoredCredsmap[string]struct {
102-
CoderURLstring`json:"coder_url"`
103-
APITokenstring`json:"api_token"`
104-
}
105-
err=json.Unmarshal(winCred.CredentialBlob,&storedCreds)
106-
require.NoError(t,err,"unmarshalling stored credentials")
107-
108-
// Both credentials should exist
109-
require.Len(t,storedCreds,2)
110-
require.Equal(t,token1,storedCreds[srv1URL.Host].APIToken)
111-
require.Equal(t,token2,storedCreds[srv2URL.Host].APIToken)
112-
113-
// Read individual credentials
114-
token,err:=backend.Read(srv1URL)
115-
require.NoError(t,err)
116-
require.Equal(t,token1,token)
117-
118-
token,err=backend.Read(srv2URL)
119-
require.NoError(t,err)
120-
require.Equal(t,token2,token)
121-
122-
// Cleanup
123-
err=backend.Delete(srv1URL)
124-
require.NoError(t,err)
125-
err=backend.Delete(srv2URL)
126-
require.NoError(t,err)
127-
}

0 commit comments

Comments
 (0)

[8]ページ先頭

©2009-2025 Movatter.jp