• 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

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

4
// Package cli contains utility for the cli
5
package cli
6

7
import (
8
        "context"
9
        _ "embed"
10
        "errors"
11
        "fmt"
12
        "net/http"
13
        "net/url"
14
        "os"
15
        "time"
16

17
        "github.com/gorilla/securecookie"
18
        "github.com/pkg/browser"
19
        "github.com/spf13/cobra"
20
        "github.com/spf13/viper"
21
        "github.com/zitadel/oidc/v3/pkg/client/rp"
22
        httphelper "github.com/zitadel/oidc/v3/pkg/http"
23
        "github.com/zitadel/oidc/v3/pkg/oidc"
24
        "google.golang.org/grpc"
25
        "google.golang.org/grpc/metadata"
26

27
        mcrypto "github.com/mindersec/minder/internal/crypto"
28
        "github.com/mindersec/minder/internal/util/cli/useragent"
29
        "github.com/mindersec/minder/internal/util/rand"
30
        "github.com/mindersec/minder/pkg/config"
31
        clientconfig "github.com/mindersec/minder/pkg/config/client"
32
)
33

34
//go:embed html/login_success.html
35
var loginSuccessHtml []byte
36

37
//go:embed html/access_denied.html
38
var accessDeniedHtml []byte
39

40
//go:embed html/generic_failure.html
41
var genericAuthFailure []byte
42

43
func requestIDInterceptor(printer func(string, ...interface{})) grpc.UnaryClientInterceptor {
×
44
        return func(
×
45
                ctx context.Context,
×
46
                method string,
×
47
                req any,
×
48
                reply any,
×
49
                cc *grpc.ClientConn,
×
50
                invoker grpc.UnaryInvoker,
×
51
                opts ...grpc.CallOption,
×
52
        ) error {
×
53
                var header metadata.MD
×
54
                opts = append(opts, grpc.Header(&header))
×
55
                err := invoker(ctx, method, req, reply, cc, opts...)
×
56
                if len(header.Get("request-id")) != 0 {
×
57
                        printer("Request ID: %s\n", header.Get("request-id")[0])
×
58
                }
×
59
                return err
×
60
        }
61
}
62

63
// GrpcForCommand is a helper for getting a testing connection from cobra flags
64
func GrpcForCommand(cmd *cobra.Command, v *viper.Viper) (*grpc.ClientConn, error) {
×
65
        clientConfig, err := config.ReadConfigFromViper[clientconfig.Config](v)
×
66
        if err != nil || clientConfig == nil {
×
67
                return nil, fmt.Errorf("unable to read config: %w", err)
×
68
        }
×
69

70
        issuerUrl := clientConfig.Identity.CLI.IssuerUrl
×
71
        clientId := clientConfig.Identity.CLI.ClientId
×
72
        realm := clientConfig.Identity.CLI.Realm
×
73

×
74
        opts := []grpc.DialOption{}
×
75
        opts = append(opts, grpc.WithUserAgent(useragent.GetUserAgent()))
×
76

×
77
        if viper.GetBool("verbose") {
×
78
                opts = append(opts, grpc.WithUnaryInterceptor(requestIDInterceptor(cmd.PrintErrf)))
×
79
        }
×
80

81
        return GetGrpcConnection(
×
82
                clientConfig.GRPCClientConfig,
×
83
                issuerUrl,
×
84
                realm,
×
85
                clientId,
×
86
                opts...,
×
87
        )
×
88
}
89

90
// EnsureCredentials is a PreRunE function to ensure that the user
91
// has valid credentials, opening a browser for login if needed.
92
func EnsureCredentials(cmd *cobra.Command, _ []string) error {
×
93
        ctx := context.Background()
×
94

×
95
        clientConfig, err := config.ReadConfigFromViper[clientconfig.Config](viper.GetViper())
×
96
        if err != nil || clientConfig == nil {
×
97
                return MessageAndError("Unable to read config", err)
×
98
        }
×
99

100
        _, err = GetToken(clientConfig.GRPCClientConfig.GetGRPCAddress(),
×
101
                []grpc.DialOption{clientConfig.GRPCClientConfig.TransportCredentialsOption()},
×
102
                clientConfig.Identity.CLI.IssuerUrl,
×
103
                clientConfig.Identity.CLI.Realm,
×
104
                clientConfig.Identity.CLI.ClientId)
×
105
        if err != nil { // or token is expired?
×
106
                tokenFile, err := LoginAndSaveCreds(ctx, cmd, clientConfig)
×
107
                if err != nil {
×
108
                        return MessageAndError("Error fetching credentials from Minder", err)
×
109
                }
×
110
                cmd.Printf("Your access credentials have been saved to %s\n", tokenFile)
×
111
        }
112
        return nil
×
113
}
114

115
// LoginAndSaveCreds runs a login flow for the user, opening a browser if needed.
116
// If the credentials need to be refreshed, the new credentials will be saved for future use.
117
func LoginAndSaveCreds(ctx context.Context, cmd *cobra.Command, clientConfig *clientconfig.Config) (string, error) {
×
118
        skipBrowser := viper.GetBool("login.skip-browser")
×
119

×
120
        // wait for the token to be received
×
121
        var loginErr loginError
×
122
        token, err := Login(ctx, cmd, clientConfig, nil, skipBrowser)
×
123
        if errors.As(err, &loginErr) && loginErr.isAccessDenied() {
×
124
                return "", errors.New("Access denied. Please run the command again and accept the terms and conditions.")
×
125
        }
×
126
        if err != nil {
×
127
                return "", err
×
128
        }
×
129

130
        // save credentials
NEW
131
        filePath, err := SaveCredentials(clientConfig.GRPCClientConfig.GetGRPCAddress(),
×
NEW
132
                OpenIdCredentials{
×
NEW
133
                        AccessToken:          token.AccessToken,
×
NEW
134
                        RefreshToken:         token.RefreshToken,
×
NEW
135
                        AccessTokenExpiresAt: token.Expiry,
×
NEW
136
                })
×
137
        if err != nil {
×
138
                cmd.PrintErrf("couldn't save credentials: %s\n", err)
×
139
                return "", err
×
140
        }
×
141

142
        return filePath, err
×
143
}
144

145
type loginError struct {
146
        ErrorType   string
147
        Description string
148
}
149

150
func (e loginError) Error() string {
×
151
        return fmt.Sprintf("Error: %s\nDescription: %s\n", e.ErrorType, e.Description)
×
152
}
×
153

154
func (e loginError) isAccessDenied() bool {
×
155
        return e.ErrorType == "access_denied"
×
156
}
×
157

158
func writeError(w http.ResponseWriter, loginerr loginError) (string, error) {
×
159
        w.Header().Set("Content-Type", "text/html; charset=utf-8")
×
160
        w.Header().Set("Content-Type", "text/html; charset=utf-8")
×
161

×
162
        htmlPage := genericAuthFailure
×
163
        msg := "Access Denied."
×
164

×
165
        if loginerr.isAccessDenied() {
×
166
                htmlPage = accessDeniedHtml
×
167
                msg = "Access Denied. Please accept the terms and conditions"
×
168
        }
×
169

170
        _, err := w.Write(htmlPage)
×
171
        if err != nil {
×
172
                return msg, err
×
173
        }
×
174
        return "", nil
×
175
}
176

177
// Login is a helper function to handle the login process
178
// and return the access token
179
func Login(
180
        ctx context.Context,
181
        cmd *cobra.Command,
182
        cfg *clientconfig.Config,
183
        extraScopes []string,
184
        skipBroswer bool,
185
) (*oidc.Tokens[*oidc.IDTokenClaims], error) {
×
186
        if cfg == nil {
×
187
                return nil, errors.New("client config is nil")
×
188
        }
×
189
        grpcCfg := cfg.GRPCClientConfig
×
190
        opts := []grpc.DialOption{grpcCfg.TransportCredentialsOption()}
×
191
        issuerUrlStr := cfg.Identity.CLI.IssuerUrl
×
192
        clientID := cfg.Identity.CLI.ClientId
×
193
        realm := cfg.Identity.CLI.Realm
×
194

×
195
        realmUrl, err := GetRealmUrl(grpcCfg.GetGRPCAddress(), opts, issuerUrlStr, realm)
×
196
        if err != nil {
×
197
                return nil, MessageAndError("Error building realm URL", err)
×
198
        }
×
199

200
        // TODO: get these from WWW-Authenticate, rather than hard-coding
201
        scopes := []string{"openid", "minder-audience"}
×
202

×
203
        if len(extraScopes) > 0 {
×
204
                scopes = append(scopes, extraScopes...)
×
205
        }
×
206

207
        callbackPath := "/auth/callback"
×
208

×
209
        errChan := make(chan loginError)
×
210

×
211
        errorHandler := func(w http.ResponseWriter, _ *http.Request, errorType string, errorDesc string, _ string) {
×
212
                loginerr := loginError{
×
213
                        ErrorType:   errorType,
×
214
                        Description: errorDesc,
×
215
                }
×
216

×
217
                msg, writeErr := writeError(w, loginerr)
×
218
                if writeErr != nil {
×
219
                        // if we cannot display the access denied page, just print an error message
×
220
                        cmd.Println(msg)
×
221
                }
×
222
                errChan <- loginerr
×
223
        }
224

225
        // create encrypted cookie handler to mitigate CSRF attacks
226
        hashKey := securecookie.GenerateRandomKey(32)
×
227
        encryptKey := securecookie.GenerateRandomKey(32)
×
228
        cookieHandler := httphelper.NewCookieHandler(hashKey, encryptKey, httphelper.WithUnsecure(),
×
229
                httphelper.WithSameSite(http.SameSiteLaxMode))
×
230
        options := []rp.Option{
×
231
                rp.WithCookieHandler(cookieHandler),
×
232
                rp.WithVerifierOpts(rp.WithIssuedAtOffset(5 * time.Second)),
×
233
                rp.WithPKCE(cookieHandler),
×
234
                rp.WithErrorHandler(errorHandler),
×
235
        }
×
236

×
237
        // Get random port
×
238
        port, err := rand.GetRandomPort()
×
239
        if err != nil {
×
240
                return nil, MessageAndError("Error getting random port", err)
×
241
        }
×
242

243
        redirectURI := &url.URL{
×
244
                Scheme: "http",
×
245
                Host:   fmt.Sprintf("localhost:%v", port),
×
246
        }
×
247
        redirectURI = redirectURI.JoinPath(callbackPath)
×
248

×
249
        provider, err := rp.NewRelyingPartyOIDC(ctx, realmUrl, clientID, "", redirectURI.String(), scopes, options...)
×
250
        if err != nil {
×
251
                return nil, MessageAndError("Error creating relying party", err)
×
252
        }
×
253

254
        stateFn := func() string {
×
255
                state, err := mcrypto.GenerateNonce()
×
256
                if err != nil {
×
257
                        cmd.PrintErrln("error generating state for login")
×
258
                        os.Exit(1)
×
259
                }
×
260
                return state
×
261
        }
262

263
        tokenChan := make(chan *oidc.Tokens[*oidc.IDTokenClaims])
×
264

×
265
        callback := func(w http.ResponseWriter, _ *http.Request,
×
266
                tokens *oidc.Tokens[*oidc.IDTokenClaims], _ string, _ rp.RelyingParty) {
×
267

×
268
                tokenChan <- tokens
×
269
                // send a success message to the browser
×
270
                w.Header().Set("Content-Type", "text/html; charset=utf-8")
×
271
                _, err := w.Write(loginSuccessHtml)
×
272
                if err != nil {
×
273
                        // if we cannot display the success page, just print a success message
×
274
                        cmd.Println("Authentication Successful")
×
275
                }
×
276
        }
277

278
        http.Handle("/login", rp.AuthURLHandler(stateFn, provider))
×
279
        http.Handle(callbackPath, rp.CodeExchangeHandler(callback, provider))
×
280

×
281
        server := &http.Server{
×
282
                Addr:              fmt.Sprintf(":%d", port),
×
283
                ReadHeaderTimeout: time.Second * 10,
×
284
        }
×
285
        // Start the server in a goroutine
×
286
        go func() {
×
287
                err := server.ListenAndServe()
×
288
                // ignore error if it's just a graceful shutdown
×
289
                if err != nil && !errors.Is(err, http.ErrServerClosed) {
×
290
                        cmd.Printf("Error starting server: %v\n", err)
×
291
                }
×
292
        }()
293

294
        defer server.Shutdown(ctx)
×
295

×
296
        // get the OAuth authorization URL
×
297
        loginUrl := fmt.Sprintf("http://localhost:%v/login", port)
×
298

×
299
        if !skipBroswer {
×
300
                // Redirect user to provider to log in
×
301
                cmd.Printf("Your browser will now be opened to: %s\n", loginUrl)
×
302

×
303
                // open user's browser to login page
×
304
                if err := browser.OpenURL(loginUrl); err != nil {
×
305
                        cmd.Printf("You may login by pasting this URL into your browser: %s\n", loginUrl)
×
306
                }
×
307
        } else {
×
308
                cmd.Printf("Skipping browser login. You may login by pasting this URL into your browser: %s\n", loginUrl)
×
309
        }
×
310

311
        cmd.Println("Please follow the instructions on the page to log in.")
×
312

×
313
        cmd.Println("Waiting for token...")
×
314

×
315
        // wait for the token to be received
×
316
        var token *oidc.Tokens[*oidc.IDTokenClaims]
×
317
        var loginErr error
×
318

×
319
        select {
×
320
        case token = <-tokenChan:
×
321
                break
×
322
        case loginErr = <-errChan:
×
323
                break
×
324
        }
325

326
        return token, loginErr
×
327
}
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