Skip to content

Commit 2d2ebed

Browse files
authored
Merge pull request #1281 from fluxcd/backport-1279-to-release/v1.8.x
[release/v1.8.x] Refactor GCR Receiver
2 parents 4ab88fe + 61b0521 commit 2d2ebed

7 files changed

Lines changed: 180 additions & 48 deletions

File tree

api/v1/receiver_types.go

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,11 @@ type ReceiverSpec struct {
7878
ResourceFilter string `json:"resourceFilter,omitempty"`
7979

8080
// SecretRef specifies the Secret containing the token used
81-
// to validate the payload authenticity.
81+
// to validate the payload authenticity. The Secret must contain a 'token'
82+
// key. For GCR receivers, the Secret must also contain an 'email' key
83+
// with the IAM service account email configured on the Pub/Sub push
84+
// subscription, and may optionally contain an 'audience' key with the
85+
// expected OIDC token audience.
8286
// +required
8387
SecretRef meta.LocalObjectReference `json:"secretRef"`
8488

config/crd/bases/notification.toolkit.fluxcd.io_receivers.yaml

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -127,7 +127,11 @@ spec:
127127
secretRef:
128128
description: |-
129129
SecretRef specifies the Secret containing the token used
130-
to validate the payload authenticity.
130+
to validate the payload authenticity. The Secret must contain a 'token'
131+
key. For GCR receivers, the Secret must also contain an 'email' key
132+
with the IAM service account email configured on the Pub/Sub push
133+
subscription, and may optionally contain an 'audience' key with the
134+
expected OIDC token audience.
131135
properties:
132136
name:
133137
description: Name of the referent.

docs/api/v1/notification.md

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -149,7 +149,11 @@ github.com/fluxcd/pkg/apis/meta.LocalObjectReference
149149
</td>
150150
<td>
151151
<p>SecretRef specifies the Secret containing the token used
152-
to validate the payload authenticity.</p>
152+
to validate the payload authenticity. The Secret must contain a &lsquo;token&rsquo;
153+
key. For GCR receivers, the Secret must also contain an &lsquo;email&rsquo; key
154+
with the IAM service account email configured on the Pub/Sub push
155+
subscription, and may optionally contain an &lsquo;audience&rsquo; key with the
156+
expected OIDC token audience.</p>
153157
</td>
154158
</tr>
155159
<tr>
@@ -366,7 +370,11 @@ github.com/fluxcd/pkg/apis/meta.LocalObjectReference
366370
</td>
367371
<td>
368372
<p>SecretRef specifies the Secret containing the token used
369-
to validate the payload authenticity.</p>
373+
to validate the payload authenticity. The Secret must contain a &lsquo;token&rsquo;
374+
key. For GCR receivers, the Secret must also contain an &lsquo;email&rsquo; key
375+
with the IAM service account email configured on the Pub/Sub push
376+
subscription, and may optionally contain an &lsquo;audience&rsquo; key with the
377+
expected OIDC token audience.</p>
370378
</td>
371379
</tr>
372380
<tr>

docs/spec/v1/receivers.md

Lines changed: 52 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -545,15 +545,53 @@ spec:
545545
#### GCR
546546

547547
When a Receiver's `.spec.type` is set to `gcr`, the controller will respond to
548-
an [HTTP webhook event payload](https://cloud.google.com/container-registry/docs/configuring-notifications#notification_examples)
549-
from Google Cloud Registry to the generated [`.status.webhookPath`](#webhook-path),
550-
while verifying the payload is legitimate using [JWT](https://cloud.google.com/pubsub/docs/push#authentication).
548+
an [HTTP webhook event payload](https://cloud.google.com/artifact-registry/docs/configure-notifications)
549+
from Google Container Registry (GCR) or Google Artifact Registry (GAR) to the
550+
generated [`.status.webhookPath`](#webhook-path), while verifying the payload is
551+
legitimate using [OIDC ID token validation](https://cloud.google.com/pubsub/docs/authenticate-push-subscriptions).
552+
553+
The controller authenticates the request by performing the following checks on
554+
the OIDC ID token from the `Authorization` header:
555+
556+
1. **Signature verification**: The token signature is validated against Google's
557+
public keys.
558+
2. **Audience verification**: The `aud` claim is verified against the expected
559+
audience (see below).
560+
3. **Issuer verification**: The `iss` claim must be `accounts.google.com` or
561+
`https://accounts.google.com`.
562+
4. **Email verification**: The `email` claim must match the service account
563+
email specified in the referenced Secret's `email` key, and `email_verified`
564+
must be `true`.
565+
566+
For this to work, [authentication must be enabled on the Pub/Sub push
567+
subscription](https://cloud.google.com/pubsub/docs/push#configure_for_push_authentication),
568+
with the OIDC service account set to the same service account specified in the
569+
Secret's `email` key.
570+
571+
##### Secret format for GCR
572+
573+
The Secret referenced by `.spec.secretRef.name` must contain the following keys:
574+
575+
| Key | Required | Description |
576+
|--------------|----------|----------------------------------------------------------------------------------------------------------------------------------------------------------------|
577+
| `token` | Yes | Random string used to salt the generated [webhook path](#webhook-path). |
578+
| `email` | Yes | The email of the IAM service account configured on the Pub/Sub push subscription for OIDC authentication. |
579+
| `audience` | No | The expected `aud` claim in the OIDC token. If omitted, the controller reconstructs it from the incoming request URL, which matches the Pub/Sub default behavior of using the push endpoint URL as the audience. Set this if you configured a custom audience on the Pub/Sub subscription. |
551580

552-
The controller verifies the request originates from Google by validating the
553-
token from the [`Authorization` header](https://cloud.google.com/pubsub/docs/push#validate_tokens).
554-
For this to work, authentication must be enabled for the Pub/Sub subscription,
555-
refer to the [Google Cloud documentation](https://cloud.google.com/pubsub/docs/push#configure_for_push_authentication)
556-
for more information.
581+
Example:
582+
583+
```yaml
584+
---
585+
apiVersion: v1
586+
kind: Secret
587+
metadata:
588+
name: gcr-webhook-token
589+
namespace: default
590+
type: Opaque
591+
stringData:
592+
token: <random token>
593+
email: <service-account>@<project>.iam.gserviceaccount.com
594+
```
557595

558596
When the verification succeeds, the request payload is unmarshalled to the
559597
expected format. If this is successful, the controller will request a
@@ -574,7 +612,7 @@ metadata:
574612
spec:
575613
type: gcr
576614
secretRef:
577-
name: webhook-token
615+
name: gcr-webhook-token
578616
resources:
579617
- apiVersion: image.toolkit.fluxcd.io/v1
580618
kind: ImageRepository
@@ -777,6 +815,11 @@ This token is used to salt the generated [webhook path](#webhook-path), and
777815
depending on the Receiver [type](#supported-receiver-types), to verify the
778816
authenticity of a request.
779817

818+
**Note:** Some receiver types require additional keys in the Secret. For
819+
example, the [GCR](#gcr) type requires an `email` key and optionally an
820+
`audience` key. Refer to the documentation for the specific receiver type for
821+
details.
822+
780823
Example:
781824

782825
```yaml

internal/server/receiver_handler_test.go

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import (
2525
"encoding/base64"
2626
"encoding/hex"
2727
"encoding/json"
28+
"fmt"
2829
"hash"
2930
"net/http"
3031
"net/http/httptest"
@@ -1211,7 +1212,7 @@ func Test_handlePayload(t *testing.T) {
12111212
Spec: apiv1.ReceiverSpec{
12121213
Type: apiv1.GCRReceiver,
12131214
SecretRef: meta.LocalObjectReference{
1214-
Name: "token",
1215+
Name: "gcr-token",
12151216
},
12161217
Resources: []apiv1.CrossNamespaceObjectReference{
12171218
{
@@ -1227,7 +1228,15 @@ func Test_handlePayload(t *testing.T) {
12271228
Conditions: []metav1.Condition{{Type: meta.ReadyCondition, Status: metav1.ConditionTrue}},
12281229
},
12291230
},
1230-
secret: testSecretWithToken,
1231+
secret: &corev1.Secret{
1232+
ObjectMeta: metav1.ObjectMeta{
1233+
Name: "gcr-token",
1234+
},
1235+
Data: map[string][]byte{
1236+
"token": []byte("token"),
1237+
"email": []byte("test@example.iam.gserviceaccount.com"),
1238+
},
1239+
},
12311240
resources: []client.Object{testReceiverResource},
12321241
expectedResourcesAnnotated: 1,
12331242
expectedResponseCode: http.StatusOK,
@@ -1401,6 +1410,12 @@ func Test_handlePayload(t *testing.T) {
14011410
logger: logger.NewLogger(logger.Options{}),
14021411
kubeClient: client,
14031412
noCrossNamespaceRefs: tt.noCrossNamespaceRefs,
1413+
gcrTokenValidator: func(_ context.Context, bearer string, expectedEmail string, expectedAudience string) error {
1414+
if bearer == "" {
1415+
return fmt.Errorf("missing authorization header")
1416+
}
1417+
return nil
1418+
},
14041419
}
14051420

14061421
data, err := json.Marshal(tt.payload)

internal/server/receiver_handlers.go

Lines changed: 88 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ import (
3838
"github.com/fluxcd/pkg/runtime/conditions"
3939
"github.com/go-logr/logr"
4040
"github.com/google/go-github/v64/github"
41+
"google.golang.org/api/idtoken"
4142
corev1 "k8s.io/api/core/v1"
4243
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
4344
"k8s.io/apimachinery/pkg/runtime/schema"
@@ -224,12 +225,23 @@ func (s *ReceiverServer) validate(ctx context.Context, receiver apiv1.Receiver,
224225
}
225226
r.Body = io.NopCloser(bytes.NewReader(b))
226227

227-
// Fetch the token.
228-
token, err := s.token(ctx, receiver)
228+
// Fetch the secret.
229+
secret, err := s.secret(ctx, receiver)
229230
if err != nil {
230-
return fmt.Errorf("unable to read token, error: %w", err)
231+
return fmt.Errorf("unable to read secret, error: %w", err)
231232
}
232233

234+
// Extract the token from the secret.
235+
secretName := types.NamespacedName{
236+
Namespace: receiver.GetNamespace(),
237+
Name: receiver.Spec.SecretRef.Name,
238+
}
239+
tokenBytes, ok := secret.Data["token"]
240+
if !ok {
241+
return fmt.Errorf("invalid %q secret data: required field 'token'", secretName)
242+
}
243+
token := string(tokenBytes)
244+
233245
logger := s.logger.WithValues(
234246
"reconciler kind", apiv1.ReceiverKind,
235247
"name", receiver.Name,
@@ -399,8 +411,6 @@ func (s *ReceiverServer) validate(ctx context.Context, receiver apiv1.Receiver,
399411
r.Body = io.NopCloser(bytes.NewReader(b))
400412
return nil
401413
case apiv1.GCRReceiver:
402-
const tokenIndex = len("Bearer ")
403-
404414
type data struct {
405415
Action string `json:"action"`
406416
Digest string `json:"digest"`
@@ -416,8 +426,34 @@ func (s *ReceiverServer) validate(ctx context.Context, receiver apiv1.Receiver,
416426
} `json:"message"`
417427
}
418428

419-
err := authenticateGCRRequest(&http.Client{}, r.Header.Get("Authorization"), tokenIndex)
420-
if err != nil {
429+
expectedEmail, ok := secret.Data["email"]
430+
_ = ok
431+
// TODO: in Flux 2.9, require the email. this will be a breaking change.
432+
// if !ok {
433+
// return fmt.Errorf("invalid secret data: required field 'email' for GCR receiver")
434+
// }
435+
436+
// Determine the expected audience. If explicitly set in the secret, use
437+
// that. Otherwise, reconstruct the webhook URL from the request, which is
438+
// the default audience used by GCR when it sends the webhook.
439+
audience := string(secret.Data["audience"])
440+
if audience == "" {
441+
scheme := "https"
442+
if r.TLS == nil {
443+
if proto := r.Header.Get("X-Forwarded-Proto"); proto != "" {
444+
scheme = proto
445+
} else {
446+
scheme = "http"
447+
}
448+
}
449+
audience = scheme + "://" + r.Host + r.URL.Path
450+
}
451+
452+
authenticate := authenticateGCRRequest
453+
if s.gcrTokenValidator != nil {
454+
authenticate = s.gcrTokenValidator
455+
}
456+
if err := authenticate(ctx, r.Header.Get("Authorization"), string(expectedEmail), audience); err != nil {
421457
return fmt.Errorf("cannot authenticate GCR request: %w", err)
422458
}
423459

@@ -499,26 +535,18 @@ func (s *ReceiverServer) validate(ctx context.Context, receiver apiv1.Receiver,
499535
return fmt.Errorf("recevier type %q not supported", receiver.Spec.Type)
500536
}
501537

502-
func (s *ReceiverServer) token(ctx context.Context, receiver apiv1.Receiver) (string, error) {
503-
token := ""
538+
func (s *ReceiverServer) secret(ctx context.Context, receiver apiv1.Receiver) (*corev1.Secret, error) {
504539
secretName := types.NamespacedName{
505540
Namespace: receiver.GetNamespace(),
506541
Name: receiver.Spec.SecretRef.Name,
507542
}
508543

509544
var secret corev1.Secret
510-
err := s.kubeClient.Get(ctx, secretName, &secret)
511-
if err != nil {
512-
return "", fmt.Errorf("unable to read token from secret %q error: %w", secretName, err)
545+
if err := s.kubeClient.Get(ctx, secretName, &secret); err != nil {
546+
return nil, fmt.Errorf("unable to read secret %q: %w", secretName, err)
513547
}
514548

515-
if val, ok := secret.Data["token"]; ok {
516-
token = string(val)
517-
} else {
518-
return "", fmt.Errorf("invalid %q secret data: required field 'token'", secretName)
519-
}
520-
521-
return token, nil
549+
return &secret, nil
522550
}
523551

524552
// requestReconciliation requests reconciliation of all the resources matching the given CrossNamespaceObjectReference by annotating them accordingly.
@@ -578,26 +606,53 @@ func (s *ReceiverServer) annotate(ctx context.Context, resource *metav1.PartialO
578606
return nil
579607
}
580608

581-
func authenticateGCRRequest(c *http.Client, bearer string, tokenIndex int) (err error) {
582-
type auth struct {
583-
Aud string `json:"aud"`
609+
// authenticateGCRRequest validates the OIDC ID token according to
610+
// https://docs.cloud.google.com/pubsub/docs/authenticate-push-subscriptions#go.
611+
func authenticateGCRRequest(ctx context.Context, bearer string, expectedEmail string, expectedAudience string) error {
612+
const bearerPrefix = "Bearer "
613+
if !strings.HasPrefix(bearer, bearerPrefix) {
614+
return fmt.Errorf("the Authorization header is missing or malformed")
584615
}
585616

586-
if len(bearer) < tokenIndex {
587-
return fmt.Errorf("the Authorization header is missing or malformed: %v", bearer)
588-
}
617+
token := bearer[len(bearerPrefix):]
589618

590-
token := bearer[tokenIndex:]
591-
url := fmt.Sprintf("https://oauth2.googleapis.com/tokeninfo?id_token=%s", token)
592-
593-
resp, err := c.Get(url)
619+
// Validate the OIDC ID token signature and claims using Google's public keys.
620+
v, err := idtoken.NewValidator(ctx)
621+
if err != nil {
622+
return fmt.Errorf("cannot create ID token validator: %w", err)
623+
}
624+
payload, err := v.Validate(ctx, token, expectedAudience)
594625
if err != nil {
595-
return fmt.Errorf("cannot verify authenticity of payload: %w", err)
626+
// Extract the actual audience from the token for logging.
627+
gotAudience := "<unknown>"
628+
if parts := strings.Split(token, "."); len(parts) == 3 {
629+
if claimsJSON, decErr := base64.RawURLEncoding.DecodeString(parts[1]); decErr == nil {
630+
var claims struct {
631+
Aud string `json:"aud"`
632+
}
633+
if json.Unmarshal(claimsJSON, &claims) == nil && claims.Aud != "" {
634+
gotAudience = claims.Aud
635+
}
636+
}
637+
}
638+
return fmt.Errorf("invalid ID token: audience is '%s', want '%s': %w", gotAudience, expectedAudience, err)
596639
}
597640

598-
var p auth
599-
if err := json.NewDecoder(resp.Body).Decode(&p); err != nil {
600-
return fmt.Errorf("cannot decode auth payload: %w", err)
641+
// Verify the token issuer.
642+
issuer, _ := payload.Claims["iss"].(string)
643+
if issuer != "accounts.google.com" && issuer != "https://accounts.google.com" {
644+
return fmt.Errorf("token issuer is '%s', want 'accounts.google.com' or 'https://accounts.google.com'", issuer)
645+
}
646+
647+
// Verify the token was issued for the expected service account.
648+
email, _ := payload.Claims["email"].(string)
649+
emailVerified, _ := payload.Claims["email_verified"].(bool)
650+
// TODO: in Flux 2.9, require the email (remove `expectedEmail != "" &&`). this will be a breaking change.
651+
if expectedEmail != "" && email != expectedEmail {
652+
return fmt.Errorf("token email is '%s', want '%s'", email, expectedEmail)
653+
}
654+
if !emailVerified {
655+
return fmt.Errorf("token email '%s' is not verified", email)
601656
}
602657

603658
return nil

internal/server/receiver_server.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,9 @@ type ReceiverServer struct {
3737
kubeClient client.Client
3838
noCrossNamespaceRefs bool
3939
exportHTTPPathMetrics bool
40+
// gcrTokenValidator overrides the default GCR OIDC token validation function.
41+
// Used in tests to avoid calling Google's servers.
42+
gcrTokenValidator func(ctx context.Context, bearer string, expectedEmail string, expectedAudience string) error
4043
}
4144

4245
// NewReceiverServer returns an HTTP server that handles webhooks

0 commit comments

Comments
 (0)