-
Notifications
You must be signed in to change notification settings - Fork 114
/
auth.go
460 lines (430 loc) · 14.3 KB
/
auth.go
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
package option
import (
"context"
"crypto/tls"
"crypto/x509"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"os"
"strings"
"connectrpc.com/connect"
"github.com/coreos/go-oidc/v3/oidc"
"github.com/golang-jwt/jwt/v5"
corev1 "k8s.io/api/core/v1"
"k8s.io/apimachinery/pkg/types"
libClient "sigs.k8s.io/controller-runtime/pkg/client"
kargoapi "github.com/akuity/kargo/api/v1alpha1"
"github.com/akuity/kargo/internal/api/config"
"github.com/akuity/kargo/internal/api/user"
"github.com/akuity/kargo/internal/kubeclient"
)
const authHeaderKey = "Authorization"
var exemptProcedures = map[string]struct{}{
"/grpc.health.v1.Health/Check": {},
"/grpc.health.v1.Health/Watch": {},
"/akuity.io.kargo.service.v1alpha1.KargoService/GetPublicConfig": {},
"/akuity.io.kargo.service.v1alpha1.KargoService/AdminLogin": {},
}
// authInterceptor implements connect.Interceptor and is used to retrieve the
// value of the Authorization header from inbound requests/connections and
// store it in the context.
type authInterceptor struct {
cfg config.ServerConfig
internalClient libClient.Client
parseUnverifiedJWTFn func(
rawToken string,
claims jwt.Claims,
) (*jwt.Token, []string, error)
verifyKargoIssuedTokenFn func(rawToken string) bool
verifyIDPIssuedTokenFn func(
ctx context.Context,
rawToken string,
) (claims, bool)
oidcTokenVerifyFn goOIDCIDTokenVerifyFn
oidcExtractClaimsFn func(*oidc.IDToken) (claims, error)
listServiceAccountsFn func(
ctx context.Context,
c claims,
) (map[string]map[types.NamespacedName]struct{}, error)
}
// goOIDCIDTokenVerifyFn is a github.com/coreos/go-oidc/v3/oidc/IDTokenVerifier.Verify() function
type goOIDCIDTokenVerifyFn func(ctx context.Context, rawIDToken string) (*oidc.IDToken, error)
// newAuthInterceptor returns an initialized *authInterceptor.
func newAuthInterceptor(
ctx context.Context,
cfg config.ServerConfig,
client libClient.Client,
) (*authInterceptor, error) {
a := &authInterceptor{
cfg: cfg,
internalClient: client,
}
if cfg.OIDCConfig != nil {
var err error
a.oidcTokenVerifyFn, err = newMultiClientVerifier(ctx, cfg)
if err != nil {
return nil, err
}
}
a.parseUnverifiedJWTFn =
jwt.NewParser(jwt.WithoutClaimsValidation()).ParseUnverified
a.verifyKargoIssuedTokenFn = a.verifyKargoIssuedToken
a.verifyIDPIssuedTokenFn = a.verifyIDPIssuedToken
a.oidcExtractClaimsFn = oidcExtractClaims
a.listServiceAccountsFn = a.listServiceAccounts
return a, nil
}
// newMultiClientVerifier returns a function that implements go-oidc IDTokenVerifier.Verify()
// but iterates through multiple verifiers. We commonly have both a CLI and Web OIDC client,
// each needing it's own OIDC verification.
func newMultiClientVerifier(ctx context.Context, cfg config.ServerConfig) (goOIDCIDTokenVerifyFn, error) {
keyset, err := getKeySet(ctx, cfg)
if err != nil {
return nil, fmt.Errorf("error getting keys from OpenID Connect provider: %w", err)
}
// verifyFuncs might have two verify funcs: the web and cli verifier
var verifyFuncs []goOIDCIDTokenVerifyFn
verifyFuncs = append(verifyFuncs, oidc.NewVerifier(
cfg.OIDCConfig.IssuerURL,
keyset,
&oidc.Config{
ClientID: cfg.OIDCConfig.ClientID,
},
).Verify)
if cfg.OIDCConfig.CLIClientID != "" {
verifyFuncs = append(verifyFuncs, oidc.NewVerifier(
cfg.OIDCConfig.IssuerURL,
keyset,
&oidc.Config{
ClientID: cfg.OIDCConfig.CLIClientID,
},
).Verify)
}
multiVerifyFunc := func(ctx context.Context, rawIDToken string) (*oidc.IDToken, error) {
errs := make([]error, 0, len(verifyFuncs))
for _, fn := range verifyFuncs {
t, err := fn(ctx, rawIDToken)
if err == nil {
// we found one that worked
return t, nil
}
errs = append(errs, err)
}
// if we get here, we've iterated all our verifiers and none of them worked.
return nil, errors.Join(errs...)
}
return multiVerifyFunc, nil
}
// getKeySet retrieves the key set from the an OpenID Connect identify provider.
//
// Note: This function purposefully does not use oidc.NewProvider() and
// provider.Verifier() because they're not flexible enough to handle the Dex
// proxy case.
func getKeySet(ctx context.Context, cfg config.ServerConfig) (oidc.KeySet, error) {
httpClient := &http.Client{}
var discoURL string
if cfg.DexProxyConfig == nil {
discoURL = fmt.Sprintf(
"%s/.well-known/openid-configuration",
cfg.OIDCConfig.IssuerURL,
)
} else {
discoURL = fmt.Sprintf(
"%s/dex/.well-known/openid-configuration",
cfg.DexProxyConfig.ServerAddr,
)
var caCertPool *x509.CertPool
if cfg.DexProxyConfig.CACertPath != "" {
caCertBytes, err := os.ReadFile(cfg.DexProxyConfig.CACertPath)
if err != nil {
return nil, fmt.Errorf("error reading CA cert file %q: %w", cfg.DexProxyConfig.CACertPath, err)
}
caCertPool = x509.NewCertPool()
if ok := caCertPool.AppendCertsFromPEM(caCertBytes); !ok {
return nil, errors.New("invalid CA cert data")
}
httpClient.Transport = &http.Transport{
TLSClientConfig: &tls.Config{
MinVersion: tls.VersionTLS12,
RootCAs: caCertPool,
},
}
}
}
discoResp, err := httpClient.Get(discoURL)
if err != nil {
return nil, fmt.Errorf("error making discovery request to OpenID Connect identity provider: %w", err)
}
defer discoResp.Body.Close()
bodyBytes, err := io.ReadAll(discoResp.Body)
if err != nil {
return nil, fmt.Errorf("error reading discovery request response body: %w", err)
}
providerCfg := struct {
KeysURL string `json:"jwks_uri"`
}{}
if err = json.Unmarshal(bodyBytes, &providerCfg); err != nil {
fmt.Println(string(bodyBytes))
return nil, fmt.Errorf("error unmarshaling discovery request response body: %w", err)
}
keysURL := providerCfg.KeysURL
if cfg.DexProxyConfig != nil {
keysURL = strings.Replace(
keysURL,
cfg.OIDCConfig.IssuerURL,
fmt.Sprintf("%s/dex", cfg.DexProxyConfig.ServerAddr),
1,
)
}
// oidc.RemoteKeySet has an internal cache and it is sometimes refreshed. It
// uses a context-bound http.Client to make the request if one is available.
// This next line binds our properly configured http.Client to the context.
ctx = oidc.ClientContext(ctx, httpClient)
return oidc.NewRemoteKeySet(ctx, keysURL), nil
}
// WrapUnary implements connect.Interceptor.
func (a *authInterceptor) WrapUnary(next connect.UnaryFunc) connect.UnaryFunc {
return func(
ctx context.Context,
req connect.AnyRequest,
) (connect.AnyResponse, error) {
var err error
if ctx, err =
a.authenticate(ctx, req.Spec().Procedure, req.Header()); err != nil {
return nil, connect.NewError(connect.CodeUnauthenticated, err)
}
return next(ctx, req)
}
}
// WrapStreamingClient implements connect.Interceptor.
func (a *authInterceptor) WrapStreamingClient(
next connect.StreamingClientFunc,
) connect.StreamingClientFunc {
return func(
ctx context.Context,
spec connect.Spec,
) connect.StreamingClientConn {
// This is a no-op because this interceptor is only used with handlers.
return next(ctx, spec)
}
}
// WrapStreamingHandler implements connect.Interceptor.
func (a *authInterceptor) WrapStreamingHandler(
next connect.StreamingHandlerFunc,
) connect.StreamingHandlerFunc {
return func(ctx context.Context, conn connect.StreamingHandlerConn) error {
var err error
if ctx, err = a.authenticate(
ctx,
conn.Spec().Procedure,
conn.RequestHeader(),
); err != nil {
return connect.NewError(connect.CodeUnauthenticated, err)
}
return next(ctx, conn)
}
}
func (a *authInterceptor) listServiceAccounts(
ctx context.Context,
c claims,
) (map[string]map[types.NamespacedName]struct{}, error) {
queries := []libClient.MatchingFields{
{
kubeclient.ServiceAccountsByOIDCSubjectIndexField: c.Subject,
},
{
kubeclient.ServiceAccountsByOIDCEmailIndexField: c.Email,
},
}
for _, group := range c.Groups {
queries = append(queries, libClient.MatchingFields{
kubeclient.ServiceAccountsByOIDCGroupIndexField: group,
})
}
// allowedNamespaces is a set of all namespaces in which to search for
// ServiceAccounts the user may be mapped to. These will includes all project
// namespaces and any additional namespaces that the Kargo admin has
// designated.
allowedNamespaces := make(map[string]struct{})
if a.cfg.OIDCConfig != nil {
// Add namespaces designated by the Kargo admin to the set.
for _, ns := range a.cfg.OIDCConfig.GlobalServiceAccountNamespaces {
allowedNamespaces[ns] = struct{}{}
}
}
// Find all project namespaces.
nsList := &corev1.NamespaceList{}
if err := a.internalClient.List(ctx, nsList, libClient.MatchingLabels{
kargoapi.ProjectLabelKey: kargoapi.LabelTrueValue,
}); err != nil {
return nil, fmt.Errorf("list namespaces: %w", err)
}
// Add all project namespaces to the set.
for _, ns := range nsList.Items {
allowedNamespaces[ns.GetName()] = struct{}{}
}
// Now search all identified namespaces for ServiceAccounts that the user may
// be mapped to.
accounts := make(map[string]map[types.NamespacedName]struct{})
for _, query := range queries {
// List ALL ServiceAccounts matching the query.
list := &corev1.ServiceAccountList{}
if err := a.internalClient.List(ctx, list, query); err != nil {
return nil, fmt.Errorf("list service accounts: %w", err)
}
for _, sa := range list.Items {
// Skip if it's not in a namespace we care about.
if _, ok := allowedNamespaces[sa.GetNamespace()]; !ok {
continue
}
key := types.NamespacedName{
Namespace: sa.GetNamespace(),
Name: sa.GetName(),
}
if _, ok := accounts[key.Namespace]; !ok {
accounts[key.Namespace] = make(map[types.NamespacedName]struct{})
}
accounts[key.Namespace][key] = struct{}{}
}
}
return accounts, nil
}
// authenticate retrieves the value of the Authorization header from inbound
// requests/connections, attempts to validate it and extract meaningful user
// information from it. If successful, it stores the user information in the
// context. If unsuccessful for any reason, it returns an error. It is the
// caller's responsibility to wrap the error appropriately to convey to the
// client that an authentication failure has occurred.
func (a *authInterceptor) authenticate(
ctx context.Context,
procedure string,
header http.Header,
) (context.Context, error) {
if _, ok := exemptProcedures[procedure]; ok {
return ctx, nil
}
rawToken := strings.TrimPrefix(header.Get(authHeaderKey), "Bearer ")
if rawToken == "" {
return ctx, errors.New("no token provided")
}
// Are we dealing with a JWT?
//
// Note: If this is a JWT, we cannot trust these claims yet because we're not
// verifying the token yet. We use untrustedClaims.Issuer only as a hint as to
// HOW we might be able to verify the token further.
untrustedClaims := jwt.RegisteredClaims{}
if _, _, err := a.parseUnverifiedJWTFn(rawToken, &untrustedClaims); err != nil {
// This token isn't a JWT, so it's probably an opaque bearer token for the
// Kubernetes API server. Just run with it. If we're wrong, Kubernetes API
// calls will simply have auth errors that will bubble back to the client.
return user.ContextWithInfo(
ctx,
user.Info{
BearerToken: rawToken,
},
), nil
}
// If we get to here, we're dealing with a JWT. It could have been issued:
//
// 1. Directly by the Kargo API server (in the case of admin)
// 2. By Kargo's OpenID Connect identity provider
// 3. By the Kubernetes cluster's identity provider
// 4. By Kubernetes itself (a service account token, perhaps)
if a.cfg.AdminConfig != nil &&
untrustedClaims.Issuer == a.cfg.AdminConfig.TokenIssuer {
// Case 1: This token was allegedly issued directly by the Kargo API server.
if a.verifyKargoIssuedTokenFn(rawToken) {
return user.ContextWithInfo(
ctx,
user.Info{
IsAdmin: true,
},
), nil
}
return ctx, errors.New("invalid token")
}
if a.cfg.OIDCConfig != nil &&
untrustedClaims.Issuer == a.cfg.OIDCConfig.IssuerURL {
// Case 2: This token was allegedly issued by Kargo's OpenID Connect
// identity provider.
c, ok := a.verifyIDPIssuedTokenFn(ctx, rawToken)
if ok {
sa, err := a.listServiceAccountsFn(ctx, c)
if err != nil {
return ctx, fmt.Errorf("list service accounts for user: %w", err)
}
return user.ContextWithInfo(
ctx,
user.Info{
Subject: c.Subject,
Email: c.Email,
Groups: c.Groups,
ServiceAccountsByNamespace: sa,
},
), nil
}
return ctx, errors.New("invalid token")
}
// Case 3 or 4: We don't know how to verify this token. It's probably a token
// issued by the Kubernetes cluster's identity provider. Just run with it. If
// we're wrong, Kubernetes API calls will simply have auth errors that will
// bubble back to the client.
return user.ContextWithInfo(
ctx,
user.Info{
BearerToken: rawToken,
},
), nil
}
// verifyIDPIssuedToken attempts to verify that the provided raw token was
// issued by Kargo's OpenID Connect identity provider. On success, select claims
// are extracted and returned along with a true boolean. If the provided raw
// token couldn't be verified, the returned boolean is false. A non-nil error is
// only ever returned if something goes wrong AFTER successfully verifying the
// token. Callers may infer that if the returned error is nil, but the returned
// boolean is false, the provided raw token could not be verified.
func (a *authInterceptor) verifyIDPIssuedToken(
ctx context.Context,
rawToken string,
) (claims, bool) {
c := claims{}
if a.oidcTokenVerifyFn == nil {
return c, false
}
token, err := a.oidcTokenVerifyFn(ctx, rawToken)
if err != nil {
return c, false
}
if c, err = a.oidcExtractClaimsFn(token); err != nil {
return c, false
}
return c, true
}
// verifyKargoIssuedToken attempts to verify that the provided raw token was
// issued directly by the Kargo API server and returns a boolean value
// indicating success (true) or failure (false).
func (a *authInterceptor) verifyKargoIssuedToken(rawToken string) bool {
if a.cfg.AdminConfig == nil {
return false
}
_, err := jwt.NewParser().Parse(
rawToken,
func(*jwt.Token) (any, error) {
return a.cfg.AdminConfig.TokenSigningKey, nil
},
)
return err == nil
}
type claims struct {
Subject string `json:"sub"`
Email string `json:"email"`
Groups []string `json:"groups"`
}
func oidcExtractClaims(token *oidc.IDToken) (claims, error) {
c := claims{}
err := token.Claims(&c)
return c, err
}