Skip to content

Commit 18592bd

Browse files
authored
Add option to use credentials from CLI (#244)
The Oxide CLI stores authentication information in a well-known format. For convenience, we should should allow SDK users to use these, rather then requiring that it be manually set in the environment or directly set. Add functionality to `Config` to use Oxide config files, either with a default profile, or with an explicitly provided profile name. We use the files found in `$HOME/.config/oxide` by default. This can be can be overridden with the `ConfigDir` field. As with `Host`/`Token`, profiles will override the `OXIDE_HOST`/`OXIDE_TOKEN` environment variables. `Profile` and `UseDefaultProfile` are mutually exclusive with each other and the `Host`/`Token` options to prevent confusion on the order or precedence.
1 parent 88b2bfd commit 18592bd

File tree

5 files changed

+354
-30
lines changed

5 files changed

+354
-30
lines changed

.changelog/v0.1.0-beta10.toml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,8 @@ title = ""
33
description = ""
44

55
[[features]]
6-
title = ""
7-
description = ""
6+
title = "Authenticate using Oxide credentials.toml"
7+
description = "Add option to authenticate using the `credentials.toml` file generated by the Oxide CLI. [244](https://github.com/oxidecomputer/oxide.go/pull/244)"
88

99
[[bugs]]
1010
title = ""

go.mod

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ go 1.22
55
require (
66
github.com/getkin/kin-openapi v0.128.0
77
github.com/iancoleman/strcase v0.3.0
8+
github.com/pelletier/go-toml v1.9.5
89
github.com/stretchr/testify v1.9.0
910
)
1011

go.sum

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@ github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0
2222
github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
2323
github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 h1:RWengNIwukTxcDr9M+97sNutRR1RKhG96O6jWumTTnw=
2424
github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826/go.mod h1:TaXosZuwdSHYgviHp1DAtfrULt5eUgsSMsZf+YrPgl8=
25+
github.com/pelletier/go-toml v1.9.5 h1:4yBQzkHv+7BHq2PQUZF3Mx0IYxG7LsP222s7Agd3ve8=
26+
github.com/pelletier/go-toml v1.9.5/go.mod h1:u1nR/EPcESfeI/szUZKdtJ0xRNbUoANCkoOuaOx1Y+c=
2527
github.com/perimeterx/marshmallow v1.1.5 h1:a2LALqQ1BlHM8PZblsDdidgv1mWi1DgC2UmX50IvK2s=
2628
github.com/perimeterx/marshmallow v1.1.5/go.mod h1:dsXbUu8CRzfYP5a87xpp0xq9S3u0Vchtcl8we9tYaXw=
2729
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=

oxide/lib.go

Lines changed: 142 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -12,15 +12,29 @@ import (
1212
"net/http"
1313
"net/url"
1414
"os"
15+
"path/filepath"
1516
"strings"
1617
"time"
18+
19+
"github.com/pelletier/go-toml"
1720
)
1821

19-
// TokenEnvVar is the environment variable that contains the token.
20-
const TokenEnvVar = "OXIDE_TOKEN"
22+
const (
23+
// TokenEnvVar is the environment variable that contains the token.
24+
TokenEnvVar = "OXIDE_TOKEN"
25+
26+
// HostEnvVar is the environment variable that contains the host.
27+
HostEnvVar = "OXIDE_HOST"
28+
29+
// credentialsFile is the name of the file the Oxide CLI stores credentials in.
30+
credentialsFile = "credentials.toml"
2131

22-
// HostEnvVar is the environment variable that contains the host.
23-
const HostEnvVar = "OXIDE_HOST"
32+
// configFile is the name of the file the Oxide CLI stores its config in.
33+
configFile = "config.toml"
34+
35+
// defaultConfigDir is the default path used by the Oxide CLI for configuration files.
36+
defaultConfigDir = ".config" + string(filepath.Separator) + "oxide"
37+
)
2438

2539
// Config is the configuration that can be set on a Client.
2640
type Config struct {
@@ -37,6 +51,20 @@ type Config struct {
3751
// A custom user agent string to add to every request instead of the
3852
// default.
3953
UserAgent string
54+
55+
// The directory to look for Oxide CLI configuration files. Defaults
56+
// to $HOME/.config/oxide if unset.
57+
ConfigDir string
58+
59+
// The name of the Oxide CLI profile to use for authentication.
60+
// The Host and Token fields will override their respective values
61+
// provided by the profile.
62+
Profile string
63+
64+
// Whether to use the default profile listed in the Oxide CLI
65+
// config.toml file for authentication. Will be overridden by
66+
// the Profile field.
67+
UseDefaultProfile bool
4068
}
4169

4270
// Client which conforms to the OpenAPI3 specification for this service.
@@ -55,11 +83,19 @@ type Client struct {
5583
userAgent string
5684
}
5785

86+
type authCredentials struct {
87+
host string
88+
token string
89+
}
90+
5891
// NewClient creates a new client for the Oxide API. To authenticate with
5992
// environment variables, set OXIDE_HOST and OXIDE_TOKEN accordingly. Pass in a
6093
// non-nil *Config to set the various configuration options on a Client. When
6194
// setting the host and token through the *Config, these will override any set
62-
// environment variables.
95+
// environment variables. The Profile and UseDefaultProfile fields will pull
96+
// authentication information from the credentials.toml file generated by
97+
// the Oxide CLI. These are mutally exclusive with each other and the Host and
98+
// Token fields.
6399
func NewClient(cfg *Config) (*Client, error) {
64100
token := os.Getenv(TokenEnvVar)
65101
host := os.Getenv(HostEnvVar)
@@ -70,6 +106,24 @@ func NewClient(cfg *Config) (*Client, error) {
70106

71107
// Layer in the user-provided configuration if provided.
72108
if cfg != nil {
109+
if cfg.Profile != "" || cfg.UseDefaultProfile {
110+
if cfg.Profile != "" && cfg.UseDefaultProfile {
111+
return nil, errors.New("cannot authenticate with both default profile and a defined profile")
112+
}
113+
114+
if cfg.Host != "" || cfg.Token != "" {
115+
return nil, errors.New("cannot authenticate with both a profile and host/token")
116+
}
117+
118+
fileCreds, err := getProfile(*cfg)
119+
if err != nil {
120+
return nil, fmt.Errorf("unable to retrieve profile: %w", err)
121+
}
122+
123+
token = fileCreds.token
124+
host = fileCreds.host
125+
}
126+
73127
if cfg.Host != "" {
74128
host = cfg.Host
75129
}
@@ -87,19 +141,18 @@ func NewClient(cfg *Config) (*Client, error) {
87141
}
88142
}
89143

90-
var errHost error
144+
errs := make([]error, 0)
91145
host, err := parseBaseURL(host)
92146
if err != nil {
93-
errHost = fmt.Errorf("failed parsing host address: %w", err)
147+
errs = append(errs, fmt.Errorf("failed parsing host address: %w", err))
94148
}
95149

96-
var errToken error
97150
if token == "" {
98-
errToken = errors.New("token is required")
151+
errs = append(errs, errors.New("token is required"))
99152
}
100153

101154
// To aggregate the validation errors above.
102-
if err := errors.Join(errHost, errToken); err != nil {
155+
if err := errors.Join(errs...); err != nil {
103156
return nil, fmt.Errorf("invalid client configuration:\n%w", err)
104157
}
105158

@@ -118,6 +171,85 @@ func defaultUserAgent() string {
118171
return fmt.Sprintf("oxide.go/%s", version)
119172
}
120173

174+
// getProfile determines the path of the user's credentials file
175+
// and returns the host and token for the requested profile.
176+
func getProfile(cfg Config) (*authCredentials, error) {
177+
configDir := cfg.ConfigDir
178+
if configDir == "" {
179+
homeDir, err := os.UserHomeDir()
180+
if err != nil {
181+
return nil, fmt.Errorf("unable to find user's home directory: %w", err)
182+
}
183+
configDir = filepath.Join(homeDir, defaultConfigDir)
184+
}
185+
186+
profile := cfg.Profile
187+
188+
// Use explicitly configured profile over default when both are set.
189+
if cfg.UseDefaultProfile && profile == "" {
190+
configPath := filepath.Join(configDir, configFile)
191+
192+
var err error
193+
profile, err = defaultProfile(configPath)
194+
if err != nil {
195+
return nil, fmt.Errorf("failed to get default profile from %q: %w", configPath, err)
196+
}
197+
}
198+
199+
credentialsPath := filepath.Join(configDir, credentialsFile)
200+
fileCreds, err := parseCredentialsFile(credentialsPath, profile)
201+
if err != nil {
202+
return nil, fmt.Errorf("failed to get credentials for profile %q from %q: %w", profile, credentialsPath, err)
203+
}
204+
205+
return fileCreds, nil
206+
}
207+
208+
// defaultProfile returns the default profile from config.toml, if present.
209+
func defaultProfile(configPath string) (string, error) {
210+
configFile, err := toml.LoadFile(configPath)
211+
if err != nil {
212+
return "", fmt.Errorf("failed to open config: %w", err)
213+
}
214+
215+
if profileName := configFile.Get("default-profile"); profileName != nil {
216+
return profileName.(string), nil
217+
}
218+
219+
return "", errors.New("no default profile set")
220+
}
221+
222+
// parseCredentialsFile parses a credentials.toml and returns the token and host
223+
// associated with the requested profile.
224+
func parseCredentialsFile(credentialsPath, profileName string) (*authCredentials, error) {
225+
if profileName == "" {
226+
return nil, errors.New("no profile name provided")
227+
}
228+
229+
credentialsFile, err := toml.LoadFile(credentialsPath)
230+
if err != nil {
231+
return nil, fmt.Errorf("failed to open %q: %v", credentialsPath, err)
232+
}
233+
234+
profile, ok := credentialsFile.Get("profile." + profileName).(*toml.Tree)
235+
if !ok {
236+
return nil, errors.New("profile not found")
237+
}
238+
239+
var hostTokenErr error
240+
token, ok := profile.Get("token").(string)
241+
if !ok {
242+
hostTokenErr = errors.New("token not found")
243+
}
244+
245+
host, ok := profile.Get("host").(string)
246+
if !ok {
247+
hostTokenErr = errors.Join(errors.New("host not found"))
248+
}
249+
250+
return &authCredentials{host: host, token: token}, hostTokenErr
251+
}
252+
121253
// parseBaseURL parses the base URL from the host URL.
122254
func parseBaseURL(baseURL string) (string, error) {
123255
if baseURL == "" {

0 commit comments

Comments
 (0)