• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

mindersec / minder / 14259522104

04 Apr 2025 06:36AM UTC coverage: 56.778%. First build
14259522104

Pull #5560

github

web-flow
Merge 3e36f673f into 7c700397e
Pull Request #5560: Support different credential files for different API hosts

20 of 28 new or added lines in 2 files covered. (71.43%)

18303 of 32236 relevant lines covered (56.78%)

36.98 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

56.94
/internal/util/cli/credentials.go
1
// SPDX-FileCopyrightText: Copyright 2023 The Minder Authors
2
// SPDX-License-Identifier: Apache-2.0
3

4
package cli
5

6
import (
7
        "context"
8
        "encoding/json"
9
        "errors"
10
        "fmt"
11
        "net/http"
12
        "net/url"
13
        "os"
14
        "path/filepath"
15
        "strings"
16
        "time"
17

18
        "google.golang.org/grpc"
19
        "google.golang.org/grpc/codes"
20
        "google.golang.org/grpc/metadata"
21
        "google.golang.org/grpc/status"
22

23
        minderv1 "github.com/mindersec/minder/pkg/api/protobuf/go/minder/v1"
24
        clientconfig "github.com/mindersec/minder/pkg/config/client"
25
)
26

27
// MinderAuthTokenEnvVar is the environment variable for the minder auth token
28
//
29
//nolint:gosec // This is not a hardcoded credential
30
const MinderAuthTokenEnvVar = "MINDER_AUTH_TOKEN"
31

32
// ErrGettingRefreshToken is an error for when we can't get a refresh token
33
var ErrGettingRefreshToken = errors.New("error refreshing credentials")
34

35
// OpenIdCredentials is a struct to hold the access and refresh tokens
36
type OpenIdCredentials struct {
37
        AccessToken          string    `json:"access_token"`
38
        RefreshToken         string    `json:"refresh_token"`
39
        AccessTokenExpiresAt time.Time `json:"expiry"`
40
}
41

42
func getCredentialsPath(serverAddress string, create bool) (string, error) {
8✔
43
        cfgPath, err := GetConfigDirPath()
8✔
44
        if err != nil {
8✔
45
                return "", fmt.Errorf("error getting config path: %v", err)
×
46
        }
×
47

48
        // Prefer the server address if presentAn
49
        preferredFile := filepath.Join(cfgPath, strings.ReplaceAll(serverAddress, ":", "_")+".json")
8✔
50
        if create {
10✔
51
                return preferredFile, nil
2✔
52
        }
2✔
53
        fi, err := os.Stat(preferredFile)
6✔
54
        if err == nil && fi.Mode().IsRegular() {
9✔
55
                return preferredFile, nil
3✔
56
        }
3✔
57

58
        // Legacy path -- read non-server-specific credentials if no server-specific file exists
59
        filePath := filepath.Join(cfgPath, "credentials.json")
3✔
60
        return filePath, nil
3✔
61
}
62

63
// JWTTokenCredentials is a helper struct for grpc
64
type JWTTokenCredentials struct {
65
        accessToken string
66
}
67

68
// GetRequestMetadata implements the PerRPCCredentials interface.
69
func (jwt JWTTokenCredentials) GetRequestMetadata(_ context.Context, _ ...string) (map[string]string, error) {
×
70
        return map[string]string{
×
71
                "authorization": "Bearer " + string(jwt.accessToken),
×
72
        }, nil
×
73
}
×
74

75
// RequireTransportSecurity implements the PerRPCCredentials interface.
76
func (JWTTokenCredentials) RequireTransportSecurity() bool {
3✔
77
        return false
3✔
78
}
3✔
79

80
// GetGrpcConnection is a helper for getting a testing connection for grpc
81
func GetGrpcConnection(
82
        cfg clientconfig.GRPCClientConfig,
83
        issuerUrl string, realm string, clientId string,
84
        opts ...grpc.DialOption) (
85
        *grpc.ClientConn, error) {
3✔
86

3✔
87
        opts = append(opts, cfg.TransportCredentialsOption())
3✔
88

3✔
89
        // read credentials
3✔
90
        token := ""
3✔
91
        if os.Getenv(MinderAuthTokenEnvVar) != "" {
5✔
92
                token = os.Getenv(MinderAuthTokenEnvVar)
2✔
93
        } else {
3✔
94
                t, err := GetToken(cfg.GetGRPCAddress(), opts, issuerUrl, realm, clientId)
1✔
95
                if err == nil {
1✔
96
                        token = t
×
97
                }
×
98
        }
99

100
        opts = append(opts, grpc.WithPerRPCCredentials(JWTTokenCredentials{accessToken: token}))
3✔
101

3✔
102
        // generate credentials
3✔
103
        conn, err := grpc.NewClient(cfg.GetGRPCAddress(), opts...)
3✔
104
        if err != nil {
3✔
105
                return nil, fmt.Errorf("error connecting to gRPC server: %v", err)
×
106
        }
×
107

108
        return conn, nil
3✔
109
}
110

111
// SaveCredentials saves the credentials to a file
112
func SaveCredentials(serverAddress string, tokens OpenIdCredentials) (string, error) {
2✔
113
        // marshal the credentials to json
2✔
114
        credsJSON, err := json.Marshal(tokens)
2✔
115
        if err != nil {
2✔
116
                return "", fmt.Errorf("error marshaling credentials: %v", err)
×
117
        }
×
118

119
        filePath, err := getCredentialsPath(serverAddress, true)
2✔
120
        if err != nil {
2✔
121
                return "", fmt.Errorf("error getting credentials path: %v", err)
×
122
        }
×
123

124
        err = os.MkdirAll(filepath.Dir(filePath), 0750)
2✔
125
        if err != nil {
2✔
126
                return "", fmt.Errorf("error creating directory: %v", err)
×
127
        }
×
128

129
        // Write the JSON data to the file
130
        err = os.WriteFile(filePath, credsJSON, 0600)
2✔
131
        if err != nil {
2✔
132
                return "", fmt.Errorf("error writing credentials to file: %v", err)
×
133
        }
×
134
        return filePath, nil
2✔
135
}
136

137
// RemoveCredentials removes the local credentials file
138
func RemoveCredentials(serverAddress string) error {
1✔
139
        filePath, err := getCredentialsPath(serverAddress, false)
1✔
140
        if err != nil {
1✔
NEW
141
                return fmt.Errorf("error getting credentials path: %v", err)
×
142
        }
×
143

144
        err = os.Remove(filePath)
1✔
145
        if err != nil {
1✔
146
                return fmt.Errorf("error removing credentials file: %v", err)
×
147
        }
×
148
        return nil
1✔
149
}
150

151
// GetToken retrieves the access token from the credentials file and refreshes it if necessary
152
func GetToken(serverAddress string, opts []grpc.DialOption, issuerUrl string, realm string, clientId string) (string, error) {
1✔
153
        refreshLimit := 10 * time.Second
1✔
154
        creds, err := LoadCredentials(serverAddress)
1✔
155
        if err != nil {
2✔
156
                return "", fmt.Errorf("error loading credentials: %v", err)
1✔
157
        }
1✔
158
        needsRefresh := time.Now().Add(refreshLimit).After(creds.AccessTokenExpiresAt)
×
159

×
160
        if needsRefresh {
×
161
                realmUrl, err := GetRealmUrl(serverAddress, opts, issuerUrl, realm)
×
162
                if err != nil {
×
163
                        return "", fmt.Errorf("error building realm URL: %v", err)
×
164
                }
×
165
                // TODO: this should probably use rp.NewRelyingPartyOIDC from zitadel, rather than making its own URL
166
                parsedUrl, err := url.Parse(realmUrl)
×
167
                if err != nil {
×
168
                        return "", fmt.Errorf("error parsing realm URL: %v", err)
×
169
                }
×
170
                parsedUrl = parsedUrl.JoinPath("protocol/openid-connect/token")
×
NEW
171
                updatedCreds, err := RefreshCredentials(serverAddress, creds.RefreshToken, parsedUrl.String(), clientId)
×
172
                if err != nil {
×
173
                        return "", fmt.Errorf("%w: %v", ErrGettingRefreshToken, err)
×
174
                }
×
175
                return updatedCreds.AccessToken, nil
×
176
        }
177

178
        return creds.AccessToken, nil
×
179
}
180

181
type refreshTokenResponse struct {
182
        AccessToken          string `json:"access_token"`
183
        RefreshToken         string `json:"refresh_token"`
184
        AccessTokenExpiresIn int    `json:"expires_in"`
185
        // These will be present if there's an error
186
        Error            string `json:"error"`
187
        ErrorDescription string `json:"error_description"`
188
}
189

190
// GetRealmUrl determines the authentication realm URL, preferring to fetch it from
191
// the server headers using the minder.v1.UserService.GetUser method (and extracting
192
// the realm from the "WWW-Authenticate" header), but falling back to static
193
// configuration if that fails.
194
func GetRealmUrl(serverAddress string, opts []grpc.DialOption, issuerUrl string, realm string) (string, error) {
×
195
        // Try making an unauthenticated call to get the "WWW-Authenticate" header
×
196
        conn, err := grpc.NewClient(serverAddress, opts...)
×
197
        if err == nil {
×
198
                // Not much we can do about the error, and we'll exit soon anyway, since this is client code.
×
199
                defer func() { _ = conn.Close() }()
×
200
                client := minderv1.NewUserServiceClient(conn)
×
201
                var headers metadata.MD
×
202
                _, err := client.GetUser(context.Background(), &minderv1.GetUserRequest{}, grpc.Header(&headers))
×
203
                if err != nil && status.Code(err) == codes.Unauthenticated {
×
204
                        wwwAuthenticate := headers.Get("www-authenticate")
×
205
                        if len(wwwAuthenticate) > 0 && wwwAuthenticate[0] != "" {
×
206
                                authRealm := extractWWWAuthenticateRealm(wwwAuthenticate[0])
×
207
                                if authRealm != "" {
×
208
                                        return authRealm, nil
×
209
                                }
×
210
                        }
211
                }
212
                // Unable to get the header, fall back to static configuration
213
        }
214

215
        parsedURL, err := url.Parse(issuerUrl)
×
216
        if err != nil {
×
217
                return "", fmt.Errorf("error parsing issuer URL: %v", err)
×
218
        }
×
219
        return parsedURL.JoinPath("realms", realm).String(), nil
×
220
}
221

222
// Parser for https://httpwg.org/specs/rfc9110.html#auth.params
223
func extractWWWAuthenticateRealm(header string) string {
×
224
        if !strings.HasPrefix(header, "Bearer ") {
×
225
                return ""
×
226
        }
×
227
        header = strings.TrimPrefix(header, "Bearer ")
×
228
        // Extract the realm from the "WWW-Authenticate" header
×
229
        for _, part := range strings.Split(header, ",") {
×
230
                parts := strings.SplitN(part, "=", 2)
×
231
                key := strings.TrimSpace(parts[0])
×
232
                if key == "realm" && len(parts) == 2 {
×
233
                        realm := strings.TrimSpace(parts[1])
×
234
                        if strings.HasPrefix(realm, `"`) && strings.HasSuffix(realm, `"`) {
×
235
                                realm = realm[1 : len(realm)-1]
×
236
                        }
×
237
                        return realm
×
238
                }
239
        }
240
        return ""
×
241
}
242

243
// RefreshCredentials uses a refresh token to get and save a new set of credentials
244
func RefreshCredentials(serverAddress string, refreshToken string, realmUrl string, clientId string) (OpenIdCredentials, error) {
4✔
245

4✔
246
        data := url.Values{}
4✔
247
        data.Set("client_id", clientId)
4✔
248
        data.Set("grant_type", "refresh_token")
4✔
249
        data.Set("refresh_token", refreshToken)
4✔
250

4✔
251
        req, err := http.NewRequest("POST", realmUrl, strings.NewReader(data.Encode()))
4✔
252
        if err != nil {
4✔
253
                return OpenIdCredentials{}, fmt.Errorf("error creating: %v", err)
×
254
        }
×
255
        req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
4✔
256

4✔
257
        client := &http.Client{}
4✔
258
        resp, err := client.Do(req)
4✔
259
        if err != nil {
4✔
260
                return OpenIdCredentials{}, fmt.Errorf("error fetching new credentials: %v", err)
×
261
        }
×
262
        defer resp.Body.Close()
4✔
263

4✔
264
        tokens := refreshTokenResponse{}
4✔
265
        err = json.NewDecoder(resp.Body).Decode(&tokens)
4✔
266
        if err != nil {
6✔
267
                return OpenIdCredentials{}, fmt.Errorf("error unmarshaling credentials: %v", err)
2✔
268
        }
2✔
269

270
        if tokens.Error != "" {
3✔
271
                return OpenIdCredentials{}, fmt.Errorf("error refreshing credentials: %s: %s", tokens.Error, tokens.ErrorDescription)
1✔
272
        }
1✔
273

274
        updatedCredentials := OpenIdCredentials{
1✔
275
                AccessToken:          tokens.AccessToken,
1✔
276
                RefreshToken:         tokens.RefreshToken,
1✔
277
                AccessTokenExpiresAt: time.Now().Add(time.Duration(tokens.AccessTokenExpiresIn) * time.Second),
1✔
278
        }
1✔
279
        _, err = SaveCredentials(serverAddress, updatedCredentials)
1✔
280
        if err != nil {
1✔
281
                return OpenIdCredentials{}, fmt.Errorf("error saving credentials: %v", err)
×
282
        }
×
283

284
        return updatedCredentials, nil
1✔
285
}
286

287
// LoadCredentials loads the credentials from a file
288
func LoadCredentials(serverAddress string) (OpenIdCredentials, error) {
5✔
289
        filePath, err := getCredentialsPath(serverAddress, false)
5✔
290
        if err != nil {
5✔
291
                return OpenIdCredentials{}, fmt.Errorf("error getting credentials path: %v", err)
×
292
        }
×
293

294
        // Read the file
295
        credsJSON, err := os.ReadFile(filepath.Clean(filePath))
5✔
296
        if err != nil {
7✔
297
                return OpenIdCredentials{}, fmt.Errorf("error reading credentials file: %v", err)
2✔
298
        }
2✔
299

300
        var creds OpenIdCredentials
3✔
301
        err = json.Unmarshal(credsJSON, &creds)
3✔
302
        if err != nil {
4✔
303
                return OpenIdCredentials{}, fmt.Errorf("error unmarshaling credentials: %v", err)
1✔
304
        }
1✔
305
        return creds, nil
2✔
306
}
307

308
// RevokeOfflineToken revokes the given offline token using OAuth2.0's Token Revocation endpoint
309
// from RFC 7009.
310
func RevokeOfflineToken(token string, issuerUrl string, realm string, clientId string) error {
×
311
        return RevokeToken(token, issuerUrl, realm, clientId, "refresh_token")
×
312
}
×
313

314
// RevokeToken revokes the given token using OAuth2.0's Token Revocation endpoint
315
// from RFC 7009. The tokenHint is the type of token being revoked, such as
316
// "access_token" or "refresh_token". In the case of an offline token, the
317
// tokenHint should be "refresh_token".
318
func RevokeToken(token string, issuerUrl string, realm string, clientId string, tokenHint string) error {
2✔
319
        parsedURL, err := url.Parse(issuerUrl)
2✔
320
        if err != nil {
3✔
321
                return fmt.Errorf("error parsing issuer URL: %v", err)
1✔
322
        }
1✔
323
        logoutUrl := parsedURL.JoinPath("realms", realm, "protocol/openid-connect/revoke")
1✔
324

1✔
325
        data := url.Values{}
1✔
326
        data.Set("client_id", clientId)
1✔
327
        data.Set("token", token)
1✔
328
        data.Set("token_type_hint", tokenHint)
1✔
329

1✔
330
        req, err := http.NewRequest("POST", logoutUrl.String(), strings.NewReader(data.Encode()))
1✔
331
        if err != nil {
1✔
332
                return fmt.Errorf("error creating: %v", err)
×
333
        }
×
334
        req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
1✔
335

1✔
336
        client := &http.Client{}
1✔
337
        resp, err := client.Do(req)
1✔
338
        if err != nil {
1✔
339
                return fmt.Errorf("error revoking token: %v", err)
×
340
        }
×
341
        defer resp.Body.Close()
1✔
342

1✔
343
        return nil
1✔
344
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc