Skip to content

Commit 4155960

Browse files
authored
feat: support using --authfile to login image repository (#231)
Signed-off-by: bcfree <[email protected]>
1 parent c1e6335 commit 4155960

File tree

5 files changed

+215
-2
lines changed

5 files changed

+215
-2
lines changed

cmd/login.go

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@ func init() {
6262
flags.StringVarP(&loginConfig.Username, "username", "u", "", "Username for login")
6363
flags.StringVarP(&loginConfig.Password, "password", "p", "", "Password for login")
6464
flags.BoolVar(&loginConfig.PasswordStdin, "password-stdin", true, "Take the password from stdin by default")
65+
flags.StringVar(&loginConfig.AuthFilePath, "authfile", "", "Path of the registry credentials file")
6566
flags.BoolVar(&loginConfig.PlainHTTP, "plain-http", false, "Allow http connections to registry")
6667
flags.BoolVar(&loginConfig.Insecure, "insecure", false, "Allow insecure connections to registry")
6768

@@ -77,8 +78,12 @@ func runLogin(ctx context.Context, registry string) error {
7778
return err
7879
}
7980

80-
// read password from stdin if password-stdin is set
81-
if loginConfig.PasswordStdin && loginConfig.Password == "" {
81+
if loginConfig.AuthFilePath != "" {
82+
loginConfig.Username, loginConfig.Password, err = config.ParseAuthFile(loginConfig.AuthFilePath, registry)
83+
if err != nil {
84+
return err
85+
}
86+
} else if loginConfig.PasswordStdin && loginConfig.Password == "" {
8287
fmt.Print("Enter password: ")
8388
password, err := terminal.ReadPassword(syscall.Stdin)
8489
if err != nil {

pkg/config/login.go

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,21 +22,42 @@ type Login struct {
2222
Username string
2323
Password string
2424
PasswordStdin bool
25+
AuthFilePath string
2526
PlainHTTP bool
2627
Insecure bool
2728
}
2829

30+
// AuthConfigEntry holds authentication credentials for a registry.
31+
type AuthConfigEntry struct {
32+
Username string `json:"username,omitempty"`
33+
Password string `json:"password,omitempty"`
34+
Auth string `json:"auth,omitempty"`
35+
}
36+
37+
// AuthConfig is a structure for dockerconfigjson‑style files.
38+
type AuthConfig struct {
39+
Auths map[string]AuthConfigEntry `json:"auths"`
40+
}
41+
2942
func NewLogin() *Login {
3043
return &Login{
3144
Username: "",
3245
Password: "",
3346
PasswordStdin: true,
47+
AuthFilePath: "",
3448
PlainHTTP: false,
3549
Insecure: false,
3650
}
3751
}
3852

3953
func (l *Login) Validate() error {
54+
if len(l.AuthFilePath) != 0 {
55+
if len(l.Username) != 0 || len(l.Password) != 0 {
56+
return fmt.Errorf("--authfile cannot be used with --username or --password")
57+
}
58+
return nil
59+
}
60+
4061
if len(l.Username) == 0 {
4162
return fmt.Errorf("missing username")
4263
}

pkg/config/login_test.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,9 @@ func TestNewLogin(t *testing.T) {
3131
if login.PasswordStdin != true {
3232
t.Errorf("expected PasswordStdin to be true, got %v", login.PasswordStdin)
3333
}
34+
if login.AuthFilePath != "" {
35+
t.Errorf("expected empty authFilePath, got %s", login.AuthFilePath)
36+
}
3437
}
3538

3639
func TestLogin_Validate(t *testing.T) {
@@ -66,6 +69,13 @@ func TestLogin_Validate(t *testing.T) {
6669
},
6770
wantErr: false,
6871
},
72+
{
73+
name: "valid login through --authfile",
74+
login: &Login{
75+
AuthFilePath: "dockerconfigjson",
76+
},
77+
wantErr: false,
78+
},
6979
}
7080

7181
for _, tt := range tests {

pkg/config/utils.go

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
/*
2+
* Copyright 2024 The CNAI Authors
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package config
18+
19+
import (
20+
"encoding/base64"
21+
"encoding/json"
22+
"errors"
23+
"fmt"
24+
"os"
25+
"strings"
26+
)
27+
28+
func ParseAuthFile(path, registry string) (string, string, error) {
29+
b, err := os.ReadFile(path)
30+
if err != nil {
31+
return "", "", fmt.Errorf("read authfile: %w", err)
32+
}
33+
var cfg AuthConfig
34+
if err = json.Unmarshal(b, &cfg); err != nil {
35+
return "", "", fmt.Errorf("decode json: %w", err)
36+
}
37+
return ExtractCred(cfg, registry)
38+
}
39+
40+
func ExtractCred(cfg AuthConfig, registry string) (user, pass string, err error) {
41+
entry, ok := cfg.Auths[registry]
42+
if !ok {
43+
return "", "", fmt.Errorf("registry %q not found in authfile", registry)
44+
}
45+
46+
switch {
47+
case entry.Username != "" && entry.Password != "":
48+
return entry.Username, entry.Password, nil
49+
case entry.Auth != "":
50+
decoded, err := base64.StdEncoding.DecodeString(entry.Auth)
51+
if err != nil {
52+
return "", "", fmt.Errorf("base64 decode: %w", err)
53+
}
54+
parts := strings.SplitN(string(decoded), ":", 2)
55+
if len(parts) != 2 {
56+
return "", "", errors.New("malformed auth (expected username:password)")
57+
}
58+
return parts[0], parts[1], nil
59+
default:
60+
return "", "", errors.New("no username/password or auth field present for registry")
61+
}
62+
}

pkg/config/utils_test.go

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
/*
2+
* Copyright 2024 The CNAI Authors
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package config
18+
19+
import (
20+
"encoding/base64"
21+
"encoding/json"
22+
"os"
23+
"testing"
24+
)
25+
26+
func TestExtractCred(t *testing.T) {
27+
b64 := base64.StdEncoding.EncodeToString([]byte("user2:pass2"))
28+
29+
cases := []struct {
30+
name string
31+
cfg AuthConfig
32+
registry string
33+
wantUser string
34+
wantPass string
35+
wantErr bool
36+
}{
37+
{
38+
name: "username password fields",
39+
cfg: AuthConfig{Auths: map[string]AuthConfigEntry{"example.io": {Username: "u", Password: "p"}}},
40+
registry: "example.io",
41+
wantUser: "u",
42+
wantPass: "p",
43+
},
44+
{
45+
name: "auth base64 field",
46+
cfg: AuthConfig{Auths: map[string]AuthConfigEntry{"registry.local": {Auth: b64}}},
47+
registry: "registry.local",
48+
wantUser: "user2",
49+
wantPass: "pass2",
50+
},
51+
{
52+
name: "registry missing",
53+
cfg: AuthConfig{Auths: map[string]AuthConfigEntry{}},
54+
registry: "miss.io",
55+
wantErr: true,
56+
},
57+
{
58+
name: "malformed auth",
59+
cfg: AuthConfig{Auths: map[string]AuthConfigEntry{"bad": {Auth: base64.StdEncoding.EncodeToString([]byte("onlyuser"))}}},
60+
registry: "bad",
61+
wantErr: true,
62+
},
63+
{
64+
name: "empty entry",
65+
cfg: AuthConfig{Auths: map[string]AuthConfigEntry{"empty": {}}},
66+
registry: "empty",
67+
wantErr: true,
68+
},
69+
}
70+
71+
for _, tc := range cases {
72+
t.Run(tc.name, func(t *testing.T) {
73+
user, pass, err := ExtractCred(tc.cfg, tc.registry)
74+
if (err != nil) != tc.wantErr {
75+
t.Fatalf("expected err=%v got %v", tc.wantErr, err)
76+
}
77+
if !tc.wantErr {
78+
if user != tc.wantUser || pass != tc.wantPass {
79+
t.Fatalf("want (%s,%s) got (%s,%s)", tc.wantUser, tc.wantPass, user, pass)
80+
}
81+
}
82+
})
83+
}
84+
}
85+
86+
// minimal I/O test to ensure ParseAuthFile ties together read+unmarshal+ExtractCred
87+
func TestParseAuthFile(t *testing.T) {
88+
cfg := AuthConfig{Auths: map[string]AuthConfigEntry{"io": {Username: "a", Password: "b"}}}
89+
90+
tmp, err := os.CreateTemp(t.TempDir(), "authfile-*.json")
91+
if err != nil {
92+
t.Fatalf("temp: %v", err)
93+
}
94+
defer tmp.Close()
95+
96+
enc := json.NewEncoder(tmp)
97+
if err := enc.Encode(cfg); err != nil {
98+
t.Fatalf("encode: %v", err)
99+
}
100+
101+
path := tmp.Name()
102+
103+
user, pass, err := ParseAuthFile(path, "io")
104+
if err != nil {
105+
t.Fatalf("unexpected err: %v", err)
106+
}
107+
if user != "a" || pass != "b" {
108+
t.Fatalf("want (a,b) got (%s,%s)", user, pass)
109+
}
110+
111+
// ensure file read error propagates
112+
if _, _, err := ParseAuthFile("/non/exist", "io"); err == nil {
113+
t.Fatalf("expected error for missing file")
114+
}
115+
}

0 commit comments

Comments
 (0)