From ecbc1b3da511eb0200e7057e89445edff79b0353 Mon Sep 17 00:00:00 2001 From: Shruti Nair Date: Wed, 5 Aug 2026 17:39:23 +0000 Subject: [PATCH] Implement OIDC token authentication for ate-api-server. --- cmd/ateapi/internal/k8sjwt/k8sjwt.go | 529 ----------------- cmd/ateapi/internal/k8sjwt/k8sjwt_test.go | 364 ------------ cmd/ateapi/internal/oidcauth/oidcauth.go | 128 +++++ cmd/ateapi/internal/oidcauth/oidcauth_test.go | 311 ++++++++++ cmd/ateapi/internal/oidcauth/verifier.go | 533 ++++++++++++++++++ .../sessionidentity/sessionidentity.go | 19 +- cmd/ateapi/main.go | 74 ++- cmd/kubectl-ate/internal/cmd/root.go | 3 + hack/install-ate.sh | 5 + internal/ateapiauth/server_test.go | 3 +- internal/ateclient/builder.go | 15 + manifests/ate-install/ate-api-server.yaml | 6 +- 12 files changed, 1074 insertions(+), 916 deletions(-) delete mode 100644 cmd/ateapi/internal/k8sjwt/k8sjwt.go delete mode 100644 cmd/ateapi/internal/k8sjwt/k8sjwt_test.go create mode 100644 cmd/ateapi/internal/oidcauth/oidcauth.go create mode 100644 cmd/ateapi/internal/oidcauth/oidcauth_test.go create mode 100644 cmd/ateapi/internal/oidcauth/verifier.go diff --git a/cmd/ateapi/internal/k8sjwt/k8sjwt.go b/cmd/ateapi/internal/k8sjwt/k8sjwt.go deleted file mode 100644 index 014d46b37..000000000 --- a/cmd/ateapi/internal/k8sjwt/k8sjwt.go +++ /dev/null @@ -1,529 +0,0 @@ -// Copyright 2026 Google LLC -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -// Package k8sjwt provides a JWT verifier tailored to Kubernetes. -package k8sjwt - -import ( - "context" - "crypto" - "crypto/ecdsa" - "crypto/elliptic" - "crypto/rsa" - "encoding/base64" - "encoding/json" - "fmt" - "hash" - "io" - "log/slog" - "math/big" - "net/http" - "slices" - "strings" - "time" -) - -// KeyAndID wraps a crypto.PublicKey along with the key ID that will identify it during -// the verification process. -// -// Use GKEKeyIDForLocallyStoredKey and GKEKeyIDForCloudKMSKey to get the correct key ID the way we -// calculate it in GKE. -type KeyAndID struct { - KeyID string - PublicKey crypto.PublicKey -} - -type parseHeader struct { - Type string `json:"typ,omitempty"` - Algorithm string `json:"alg,omitempty"` - KeyID string `json:"kid,omitempty"` -} - -type parseClaims struct { - // Claims from RFC7519 - Issuer string `json:"iss,omitempty"` - Subject string `json:"sub,omitempty"` - Audiences json.RawMessage `json:"aud,omitempty"` - Expiration float64 `json:"exp,omitempty"` - NotBefore float64 `json:"nbf,omitempty"` - IssuedAt float64 `json:"iat,omitempty"` - JTI string `json:"jti,omitempty"` - - // Kubernetes bound token claims. - BoundClaims parseBoundClaims `json:"kubernetes.io,omitempty"` - - // Kubernetes legacy token claims. - LegacyNamespace string `json:"kubernetes.io/serviceaccount/namespace,omitempty"` - LegacySecretName string `json:"kubernetes.io/serviceaccount/secret.name,omitempty"` - LegacyServiceAccountName string `json:"kubernetes.io/serviceaccount/service-account.name,omitempty"` - LegacyServiceAccountUID string `json:"kubernetes.io/serviceaccount/service-account.uid,omitempty"` -} - -type parseBoundClaims struct { - Namespace string `json:"namespace,omitempty"` - Pod parseBoundObjectReference `json:"pod,omitempty"` - ServiceAccount parseBoundObjectReference `json:"serviceaccount,omitempty"` - Secret parseBoundObjectReference `json:"secret,omitempty"` - Node parseBoundObjectReference `json:"node,omitempty"` - WarnAfter float64 `json:"warnafter,omitempty"` -} - -type parseBoundObjectReference struct { - Name string `json:"name,omitempty"` - UID string `json:"uid,omitempty"` -} - -// KubernetesClaims covers the claims that can be extracted from a newer Kubernetes bound service -// account JWT. -type KubernetesClaims struct { - // Claims from RFC7519 - Issuer string - Subject string - Audiences []string - Expiration time.Time - NotBefore time.Time - IssuedAt time.Time - JTI string - - Namespace string - - ServiceAccountName string - ServiceAccountUID string - PodName string - PodUID string - SecretName string - SecretUID string - NodeName string - NodeUID string - - WarnAfter time.Time -} - -var ( - permittedSkew = 5 * time.Minute - defaultHTTPClient = &http.Client{Timeout: 10 * time.Second} -) - -// Verify verifies and extracts claims from a Kubernetes JWT. -// -// For bound service account tokens, this function performs cryptographic verification of the JWT, -// checks the issuer and audience claims, and checks the time-binding claims. It *does not* check -// the object binding claims. If needed for your use case, you will need check the object bindings -// by connecting to the cluster and seeing if the object(s) the bindings name still exist within the -// cluster. -// -// httpClient is used for OIDC discovery and JWKS fetches; nil uses a default -// client with a whole-request timeout. -func Verify(ctx context.Context, httpClient *http.Client, jwt string, expectedIssuer, expectedAudience string, now time.Time) (*KubernetesClaims, error) { - segments := strings.Split(jwt, ".") - if len(segments) != 3 { - return nil, fmt.Errorf("malformed JWT") - } - headerB64String := segments[0] - payloadB64String := segments[1] - signatureB64String := segments[2] - - headerBytes, err := base64.RawURLEncoding.DecodeString(headerB64String) - if err != nil { - return nil, fmt.Errorf("while base64 decoding header: %w", err) - } - - signatureBytes, err := base64.RawURLEncoding.DecodeString(signatureB64String) - if err != nil { - return nil, fmt.Errorf("while base64 decoding signature: %w", err) - } - - var header parseHeader - if err := json.Unmarshal([]byte(headerBytes), &header); err != nil { - return nil, fmt.Errorf("while unmarshaling header: %w", err) - } - - // K8s JWTs don't set the `typ` header field. They might in the future, so we should tolerate the - // spec-recommended value. - switch header.Type { - case "", "JWT": // OK - default: - return nil, fmt.Errorf("unexpected value in type header") - } - - // Parse the payload. The payload is not verified at this point, so the only safe thing to do with - // it is extract the issuer, check the issuer, and fetch keys from the issuer. - // - // Don't consider any other data in the payload until the call to verifySignature() below. - payloadBytes, err := base64.RawURLEncoding.DecodeString(payloadB64String) - if err != nil { - return nil, fmt.Errorf("while base64-decoding payload: %w", err) - } - var rawClaims parseClaims - if err := json.Unmarshal(payloadBytes, &rawClaims); err != nil { - return nil, fmt.Errorf("while unmarshaling payload: %w", err) - } - - if rawClaims.Issuer != expectedIssuer { - return nil, fmt.Errorf("unexpected issuer %q", rawClaims.Issuer) - } - - // TODO: Cache keys, and only fetch new keys if the JWT's key ID is not in the cache. - keys, err := discoverKeysForIssuer(ctx, httpClient, rawClaims.Issuer) - if err != nil { - return nil, fmt.Errorf("while discovering keys from issuer: %w", err) - } - - // Find the key we should use for verification based on the key ID in the JWT header. - if header.KeyID == "" { - return nil, fmt.Errorf("key ID is required") - } - selectedKeyIndex := slices.IndexFunc(keys, func(k *KeyAndID) bool { - return k.KeyID == header.KeyID - }) - if selectedKeyIndex == -1 { - return nil, fmt.Errorf("unknown key ID %q", header.KeyID) - } - selectedKey := keys[selectedKeyIndex].PublicKey - - // Warning: don't ever refer to the payload data (except "iss") above this point. We need to - // ensure that we _never_ consider the contents of the payload when deciding how to perform - // signature verification. - if err := verifySignature(header.Algorithm, selectedKey, []byte(headerB64String+"."+payloadB64String), signatureBytes); err != nil { - return nil, fmt.Errorf("while verifying JWT signature: %w", err) - } - - // It is now safe to consider arbitrary data from the payload. - // - // At this point, the payload is mostly trusted. We know that it was really issued by the selected - // verification key, but we need to check the issuer, audience binding, and time bindings to be - // sure that it's really valid. - - // Because the JWT spec authors wanted to be fancy, we need to try to deserialize - // rawClaims.Audience both as a single string and as a slice of strings. - var singleAudience string - var audiences []string - if err := json.Unmarshal(rawClaims.Audiences, &singleAudience); err == nil { // err EQUALS nil - audiences = []string{singleAudience} - } else if err := json.Unmarshal(rawClaims.Audiences, &audiences); err == nil { // err EQUALS nil - } else { - return nil, fmt.Errorf("unable to parse audiences") - } - - // Check that our expected audience is one of the audiences in the token - if !slices.Contains(audiences, expectedAudience) { - return nil, fmt.Errorf("token is not issued for expected audience") - } - - expiration := time.Unix(int64(rawClaims.Expiration), 0) - notBefore := time.Unix(int64(rawClaims.NotBefore), 0) - issuedAt := time.Unix(int64(rawClaims.IssuedAt), 0) - - if expiration.Before(now.Add(-permittedSkew)) { - return nil, fmt.Errorf("jwt has expired") - } - - if notBefore.After(now.Add(permittedSkew)) { - return nil, fmt.Errorf("jwt is not valid yet") - } - - if issuedAt.After(now.Add(permittedSkew)) { - return nil, fmt.Errorf("jwt claims to have been issued in the future") - } - - return &KubernetesClaims{ - Issuer: rawClaims.Issuer, - Audiences: audiences, - Subject: rawClaims.Subject, - Expiration: expiration, - NotBefore: notBefore, - IssuedAt: issuedAt, - JTI: rawClaims.JTI, - - Namespace: rawClaims.BoundClaims.Namespace, - ServiceAccountName: rawClaims.BoundClaims.ServiceAccount.Name, - ServiceAccountUID: rawClaims.BoundClaims.ServiceAccount.UID, - PodName: rawClaims.BoundClaims.Pod.Name, - PodUID: rawClaims.BoundClaims.Pod.UID, - SecretName: rawClaims.BoundClaims.Secret.Name, - SecretUID: rawClaims.BoundClaims.Secret.UID, - NodeName: rawClaims.BoundClaims.Node.Name, - NodeUID: rawClaims.BoundClaims.Node.UID, - - WarnAfter: time.Unix(int64(rawClaims.BoundClaims.WarnAfter), 0), - }, nil -} - -func verifySignature(algorithm string, selectedKey crypto.PublicKey, toBeSignedBytes, signatureBytes []byte) error { - switch algorithm { - case "RS256": - rsaKey, ok := selectedKey.(*rsa.PublicKey) - if !ok { - return fmt.Errorf("requested key ID is not an RSA key") - } - toBeSignedDigest := hashBytes(crypto.SHA256.New(), toBeSignedBytes) - if err := rsa.VerifyPKCS1v15(rsaKey, crypto.SHA256, toBeSignedDigest, signatureBytes); err != nil { - return fmt.Errorf("while validating RSA PKCS1v15 signature: %w", err) - } - case "RS384": - rsaKey, ok := selectedKey.(*rsa.PublicKey) - if !ok { - return fmt.Errorf("requested key ID is not an RSA key") - } - toBeSignedDigest := hashBytes(crypto.SHA384.New(), toBeSignedBytes) - if err := rsa.VerifyPKCS1v15(rsaKey, crypto.SHA384, toBeSignedDigest, signatureBytes); err != nil { - return fmt.Errorf("while validating RSA PKCS1v15 signature: %w", err) - } - case "RS512": - rsaKey, ok := selectedKey.(*rsa.PublicKey) - if !ok { - return fmt.Errorf("requested key ID is not an RSA key") - } - toBeSignedDigest := hashBytes(crypto.SHA512.New(), toBeSignedBytes) - if err := rsa.VerifyPKCS1v15(rsaKey, crypto.SHA512, toBeSignedDigest, signatureBytes); err != nil { - return fmt.Errorf("while validating RSA PKCS1v15 signature: %w", err) - } - case "ES256": - ecdsaKey, ok := selectedKey.(*ecdsa.PublicKey) - if !ok || ecdsaKey.Curve != elliptic.P256() { - return fmt.Errorf("requested key ID is not an ECDSA P256 key") - } - toBeSignedDigest := hashBytes(crypto.SHA256.New(), toBeSignedBytes) - if len(signatureBytes) != 2*32 { - return fmt.Errorf("invalid ecdsa signature") - } - r := big.NewInt(0).SetBytes(signatureBytes[:32]) - s := big.NewInt(0).SetBytes(signatureBytes[32:]) - if !ecdsa.Verify(ecdsaKey, toBeSignedDigest, r, s) { - return fmt.Errorf("invalid ecdsa signature") - } - case "ES384": - ecdsaKey, ok := selectedKey.(*ecdsa.PublicKey) - if !ok || ecdsaKey.Curve != elliptic.P384() { - return fmt.Errorf("requested key ID is not an ECDSA P384 key") - } - toBeSignedDigest := hashBytes(crypto.SHA384.New(), toBeSignedBytes) - if len(signatureBytes) != 2*48 { - return fmt.Errorf("invalid ecdsa signature") - } - r := big.NewInt(0).SetBytes(signatureBytes[:48]) - s := big.NewInt(0).SetBytes(signatureBytes[48:]) - if !ecdsa.Verify(ecdsaKey, toBeSignedDigest, r, s) { - return fmt.Errorf("invalid ecdsa signature") - } - case "ES512": - ecdsaKey, ok := selectedKey.(*ecdsa.PublicKey) - if !ok || ecdsaKey.Curve != elliptic.P521() { - return fmt.Errorf("requested key ID is not an ECDSA P521 key") - } - toBeSignedDigest := hashBytes(crypto.SHA512.New(), toBeSignedBytes) - if len(signatureBytes) != 2*66 { - return fmt.Errorf("invalid ecdsa signature") - } - r := big.NewInt(0).SetBytes(signatureBytes[:66]) - s := big.NewInt(0).SetBytes(signatureBytes[66:]) - if !ecdsa.Verify(ecdsaKey, toBeSignedDigest, r, s) { - return fmt.Errorf("invalid ecdsa signature") - } - default: - return fmt.Errorf("unsupported algorithm %q", algorithm) - } - - return nil -} - -func hashBytes(hasher hash.Hash, bytes []byte) []byte { - hasher.Write(bytes) - hash := hasher.Sum(nil) - return hash[:] -} - -// ellipticCurveForJWK maps a JWK "crv" value to its elliptic.Curve. Only the NIST -// curves that verifySignature supports (ES256/ES384/ES512) are accepted. -func ellipticCurveForJWK(crv string) (elliptic.Curve, error) { - switch crv { - case "P-256": - return elliptic.P256(), nil - case "P-384": - return elliptic.P384(), nil - case "P-521": - return elliptic.P521(), nil - default: - return nil, fmt.Errorf("unhandled elliptic curve %q", crv) - } -} - -type oidcConfigT struct { - JWKSURI string `json:"jwks_uri"` -} - -type jwkSetT struct { - Keys []jwkT `json:"keys"` -} - -type jwkT struct { - KeyType string `json:"kty"` - KeyID string `json:"kid,omitempty"` - - EllipticCurve string `json:"crv,omitempty"` - EllipticX string `json:"x,omitempty"` - EllipticY string `json:"y,omitempty"` - - RSAN string `json:"n"` - RSAE string `json:"e"` -} - -func discoverKeysForIssuer(ctx context.Context, httpClient *http.Client, issuer string) ([]*KeyAndID, error) { - var discoveryDocURL string - if strings.HasSuffix(issuer, "/") { - discoveryDocURL = issuer + ".well-known/openid-configuration" - } else { - discoveryDocURL = issuer + "/.well-known/openid-configuration" - } - - oidcConfig, err := fetchJSON[oidcConfigT](httpClient, discoveryDocURL) - if err != nil { - return nil, fmt.Errorf("while fetching OIDC Discovery document: %w", err) - } - - slog.InfoContext(ctx, "Fetched discovery doc", slog.Any("doc", oidcConfig)) - - jwkSet, err := fetchJSON[jwkSetT](httpClient, oidcConfig.JWKSURI) - if err != nil { - return nil, fmt.Errorf("while fetching JWKS: %w", err) - } - - slog.InfoContext(ctx, "Fetched JWK set", slog.Any("jwkSet", jwkSet)) - - var ret []*KeyAndID - skipped := 0 - for _, jwk := range jwkSet.Keys { - key, err := parseJWK(jwk) - if err != nil { - // Skip an unusable key instead of failing the whole issuer; it's safe because - // keys are selected by kid and the signature is still verified, so a skipped - // key can't be abused. Debug, not Warn: unsupported key types are a normal - // config and discovery runs on every Verify (no cache yet), so Warn would spam. - slog.DebugContext(ctx, "Skipping unusable JWK from issuer", - slog.String("kid", jwk.KeyID), slog.String("kty", jwk.KeyType), slog.Any("err", err)) - skipped++ - continue - } - ret = append(ret, key) - } - - // None usable: fail here (reasons logged above) rather than return an empty set - // that fails later as a vaguer "unknown key ID". - if len(ret) == 0 { - if len(jwkSet.Keys) == 0 { - return nil, fmt.Errorf("issuer %q published an empty JWKS", issuer) - } - return nil, fmt.Errorf("no usable keys in JWKS for issuer %q (%d skipped)", issuer, skipped) - } - - if skipped > 0 { - slog.DebugContext(ctx, "Skipped unusable JWKs from issuer", - slog.String("issuer", issuer), slog.Int("skipped", skipped), slog.Int("usable", len(ret))) - } - - return ret, nil -} - -// parseJWK converts a single JWK into a verification key, returning an error for a key -// the verifier cannot use (missing key ID, unsupported key type or curve, or malformed -// parameters). -func parseJWK(jwk jwkT) (*KeyAndID, error) { - if jwk.KeyID == "" { - return nil, fmt.Errorf("JWK has no key ID") - } - switch jwk.KeyType { - case "EC": - curve, err := ellipticCurveForJWK(jwk.EllipticCurve) - if err != nil { - return nil, err - } - if jwk.EllipticX == "" || jwk.EllipticY == "" { - return nil, fmt.Errorf("EC JWK is missing the x or y coordinate") - } - xBytes, err := base64.RawURLEncoding.DecodeString(jwk.EllipticX) - if err != nil { - return nil, fmt.Errorf("while base64-decoding EC x coordinate: %w", err) - } - yBytes, err := base64.RawURLEncoding.DecodeString(jwk.EllipticY) - if err != nil { - return nil, fmt.Errorf("while base64-decoding EC y coordinate: %w", err) - } - x := new(big.Int).SetBytes(xBytes) - y := new(big.Int).SetBytes(yBytes) - // Reject coordinates outside the field. This is a cheap sanity check; the - // authoritative on-curve validation happens in ecdsa.Verify, which returns - // false for a public key whose point is not on the curve. - p := curve.Params().P - if x.Cmp(p) >= 0 || y.Cmp(p) >= 0 { - return nil, fmt.Errorf("EC JWK coordinate is out of range for curve %q", jwk.EllipticCurve) - } - return &KeyAndID{ - KeyID: jwk.KeyID, - PublicKey: &ecdsa.PublicKey{Curve: curve, X: x, Y: y}, - }, nil - - case "RSA": - nBytes, err := base64.RawURLEncoding.DecodeString(jwk.RSAN) - if err != nil { - return nil, fmt.Errorf("while base64-decoding n: %w", err) - } - n := &big.Int{} - n.SetBytes(nBytes) - - eBytes, err := base64.RawURLEncoding.DecodeString(jwk.RSAE) - if err != nil { - return nil, fmt.Errorf("while base64-decoding e: %w", err) - } - e := &big.Int{} - e.SetBytes(eBytes) - - return &KeyAndID{ - KeyID: jwk.KeyID, - PublicKey: &rsa.PublicKey{ - N: n, - E: int(e.Int64()), - }, - }, nil - - default: - return nil, fmt.Errorf("unhandled key type %q", jwk.KeyType) - } -} - -func fetchJSON[T any](httpClient *http.Client, url string) (T, error) { - var parsedBody T - if httpClient == nil { - httpClient = defaultHTTPClient - } - resp, err := httpClient.Get(url) - if err != nil { - return parsedBody, fmt.Errorf("while making HTTP request: %w", err) - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - return parsedBody, fmt.Errorf("non-200 response code %d", resp.StatusCode) - } - - bodyBytes, err := io.ReadAll(resp.Body) - if err != nil { - return parsedBody, fmt.Errorf("while reading response body: %w", err) - } - - if err := json.Unmarshal(bodyBytes, &parsedBody); err != nil { - return parsedBody, fmt.Errorf("while parsing response body: %w", err) - } - - return parsedBody, nil -} diff --git a/cmd/ateapi/internal/k8sjwt/k8sjwt_test.go b/cmd/ateapi/internal/k8sjwt/k8sjwt_test.go deleted file mode 100644 index ad3d80ac4..000000000 --- a/cmd/ateapi/internal/k8sjwt/k8sjwt_test.go +++ /dev/null @@ -1,364 +0,0 @@ -// Copyright 2026 Google LLC -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -package k8sjwt - -import ( - "context" - "crypto" - "crypto/ecdsa" - "crypto/elliptic" - "crypto/rand" - "crypto/rsa" - "crypto/sha256" - "crypto/sha512" - "encoding/base64" - "encoding/json" - "math/big" - "net/http" - "net/http/httptest" - "strings" - "sync" - "testing" - "time" -) - -const testAudience = "ate-api" - -func b64url(b []byte) string { return base64.RawURLEncoding.EncodeToString(b) } - -// testIssuer serves the OIDC discovery document and a JWKS built from the keys -// registered on it, standing in for a Kubernetes API server's OIDC endpoints. -type testIssuer struct { - server *httptest.Server - jwks jwkSetT -} - -func newTestIssuer(t *testing.T) *testIssuer { - t.Helper() - ti := &testIssuer{} - mux := http.NewServeMux() - mux.HandleFunc("/.well-known/openid-configuration", func(w http.ResponseWriter, _ *http.Request) { - writeJSON(t, w, oidcConfigT{JWKSURI: ti.server.URL + "/jwks"}) - }) - mux.HandleFunc("/jwks", func(w http.ResponseWriter, _ *http.Request) { - writeJSON(t, w, ti.jwks) - }) - ti.server = httptest.NewServer(mux) - t.Cleanup(ti.server.Close) - return ti -} - -func (ti *testIssuer) issuer() string { return ti.server.URL } - -func (ti *testIssuer) addRSA(kid string, pub *rsa.PublicKey) { - ti.jwks.Keys = append(ti.jwks.Keys, jwkT{ - KeyType: "RSA", - KeyID: kid, - RSAN: b64url(pub.N.Bytes()), - RSAE: b64url(big.NewInt(int64(pub.E)).Bytes()), - }) -} - -func (ti *testIssuer) addEC(t *testing.T, kid, crv string, pub *ecdsa.PublicKey) { - t.Helper() - // Use the ecdh bridge to read the point rather than the deprecated - // ecdsa.PublicKey.X/Y fields. Bytes() returns the uncompressed SEC1 encoding - // (0x04 || X || Y), each coordinate padded to the field size. - ecdhPub, err := pub.ECDH() - if err != nil { - t.Fatalf("converting EC key to ECDH: %v", err) - } - raw := ecdhPub.Bytes() - size := (pub.Curve.Params().BitSize + 7) / 8 - ti.jwks.Keys = append(ti.jwks.Keys, jwkT{ - KeyType: "EC", - KeyID: kid, - EllipticCurve: crv, - EllipticX: b64url(raw[1 : 1+size]), - EllipticY: b64url(raw[1+size:]), - }) -} - -func writeJSON(t *testing.T, w http.ResponseWriter, v any) { - t.Helper() - if err := json.NewEncoder(w).Encode(v); err != nil { - t.Errorf("encoding test response: %v", err) - } -} - -// validClaims returns a set of claims that Verify should accept for issuer. -func validClaims(issuer string) map[string]any { - now := time.Now() - return map[string]any{ - "iss": issuer, - "sub": "system:serviceaccount:ate-system:atelet", - "aud": []string{testAudience}, - "exp": now.Add(time.Hour).Unix(), - "nbf": now.Add(-time.Minute).Unix(), - "iat": now.Add(-time.Minute).Unix(), - "jti": "test-jti", - } -} - -// mintJWT signs a compact JWT the way a Kubernetes issuer would: RS* via -// PKCS1v15, ES* via a fixed-width r||s signature (not ASN.1), matching what -// verifySignature expects. A "" kid omits the header field. -func mintJWT(t *testing.T, alg, kid string, priv any, claims map[string]any) string { - t.Helper() - header := map[string]string{"alg": alg, "typ": "JWT"} - if kid != "" { - header["kid"] = kid - } - hb, err := json.Marshal(header) - if err != nil { - t.Fatalf("marshaling header: %v", err) - } - cb, err := json.Marshal(claims) - if err != nil { - t.Fatalf("marshaling claims: %v", err) - } - signingInput := b64url(hb) + "." + b64url(cb) - - var sig []byte - switch k := priv.(type) { - case *rsa.PrivateKey: - digest, hashID := rsaDigest(t, alg, signingInput) - sig, err = rsa.SignPKCS1v15(rand.Reader, k, hashID, digest) - if err != nil { - t.Fatalf("signing RSA: %v", err) - } - case *ecdsa.PrivateKey: - digest := ecdsaDigest(t, alg, signingInput) - r, s, err := ecdsa.Sign(rand.Reader, k, digest) - if err != nil { - t.Fatalf("signing ECDSA: %v", err) - } - size := (k.Curve.Params().BitSize + 7) / 8 - sig = make([]byte, 2*size) - r.FillBytes(sig[:size]) - s.FillBytes(sig[size:]) - default: - t.Fatalf("unsupported key type %T", priv) - } - return signingInput + "." + b64url(sig) -} - -func rsaDigest(t *testing.T, alg, input string) ([]byte, crypto.Hash) { - t.Helper() - switch alg { - case "RS256": - d := sha256.Sum256([]byte(input)) - return d[:], crypto.SHA256 - case "RS384": - d := sha512.Sum384([]byte(input)) - return d[:], crypto.SHA384 - case "RS512": - d := sha512.Sum512([]byte(input)) - return d[:], crypto.SHA512 - default: - t.Fatalf("unsupported RSA alg %q", alg) - return nil, 0 - } -} - -func ecdsaDigest(t *testing.T, alg, input string) []byte { - t.Helper() - switch alg { - case "ES256": - d := sha256.Sum256([]byte(input)) - return d[:] - case "ES384": - d := sha512.Sum384([]byte(input)) - return d[:] - case "ES512": - d := sha512.Sum512([]byte(input)) - return d[:] - default: - t.Fatalf("unsupported ECDSA alg %q", alg) - return nil - } -} - -var ( - rsaKeyOnce sync.Once - rsaKeyVal *rsa.PrivateKey -) - -// testRSAKey returns a process-wide 2048-bit RSA key, generated once, to keep the -// suite fast (RSA keygen dominates otherwise). -func testRSAKey(t *testing.T) *rsa.PrivateKey { - t.Helper() - rsaKeyOnce.Do(func() { - k, err := rsa.GenerateKey(rand.Reader, 2048) - if err != nil { - panic(err) - } - rsaKeyVal = k - }) - return rsaKeyVal -} - -// TestVerifyECDSA is the regression test for the bug where EC keys could never be -// parsed from a JWKS (discoverKeysForIssuer's EC case had only a default error), -// even though verifySignature implements ES256/ES384/ES512. -func TestVerifyECDSA(t *testing.T) { - cases := []struct { - alg string - crv string - curve elliptic.Curve - }{ - {"ES256", "P-256", elliptic.P256()}, - {"ES384", "P-384", elliptic.P384()}, - {"ES512", "P-521", elliptic.P521()}, - } - for _, tc := range cases { - t.Run(tc.alg, func(t *testing.T) { - ti := newTestIssuer(t) - key, err := ecdsa.GenerateKey(tc.curve, rand.Reader) - if err != nil { - t.Fatalf("GenerateKey: %v", err) - } - ti.addEC(t, "ec-1", tc.crv, &key.PublicKey) - tok := mintJWT(t, tc.alg, "ec-1", key, validClaims(ti.issuer())) - - if _, err := Verify(context.Background(), ti.server.Client(), tok, ti.issuer(), testAudience, time.Now()); err != nil { - t.Fatalf("Verify(%s) = %v, want nil", tc.alg, err) - } - }) - } -} - -// TestVerifyRejectsECKeyForRSAlg covers a path newly reachable now that EC keys -// load: an RS256 token whose kid names an EC key is rejected on the key-type -// mismatch, not verified. -func TestVerifyRejectsECKeyForRSAlg(t *testing.T) { - ti := newTestIssuer(t) - ecKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) - if err != nil { - t.Fatalf("GenerateKey: %v", err) - } - ti.addEC(t, "ec-1", "P-256", &ecKey.PublicKey) - // RS256 header pointing at the EC key; the RSA signing key is irrelevant because - // the key-type check fails before signature verification. - tok := mintJWT(t, "RS256", "ec-1", testRSAKey(t), validClaims(ti.issuer())) - if _, err := Verify(context.Background(), ti.server.Client(), tok, ti.issuer(), testAudience, time.Now()); err == nil { - t.Fatal("Verify accepted an RS256 token whose kid names an EC key") - } -} - -// TestVerifyMixedJWKS covers the more severe symptom of the same bug: before the -// fix, a single EC key anywhere in the JWKS made key discovery fail for the whole -// issuer, so even RS256 tokens from that issuer stopped verifying. -func TestVerifyMixedJWKS(t *testing.T) { - ti := newTestIssuer(t) - rsaKey := testRSAKey(t) - ti.addRSA("rsa-1", &rsaKey.PublicKey) - ecKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) - if err != nil { - t.Fatalf("GenerateKey: %v", err) - } - ti.addEC(t, "ec-1", "P-256", &ecKey.PublicKey) - - tok := mintJWT(t, "RS256", "rsa-1", rsaKey, validClaims(ti.issuer())) - if _, err := Verify(context.Background(), ti.server.Client(), tok, ti.issuer(), testAudience, time.Now()); err != nil { - t.Fatalf("Verify with a mixed RSA+EC JWKS = %v, want nil", err) - } -} - -// TestVerifyUnusableJWKSKeySkipped pins the resilient discovery contract: a key the -// verifier cannot parse (here an unsupported P-192 curve) is skipped rather than -// failing discovery for the whole issuer, so a token signed by a supported key in -// the same JWKS still verifies — while a token that references the skipped key is -// still rejected (skipping is not accepting). -func TestVerifyUnusableJWKSKeySkipped(t *testing.T) { - ti := newTestIssuer(t) - rsaKey := testRSAKey(t) - ti.addRSA("rsa-1", &rsaKey.PublicKey) - ti.jwks.Keys = append(ti.jwks.Keys, jwkT{ - KeyType: "EC", KeyID: "ec-bad", EllipticCurve: "P-192", EllipticX: "AA", EllipticY: "AA", - }) - - good := mintJWT(t, "RS256", "rsa-1", rsaKey, validClaims(ti.issuer())) - if _, err := Verify(context.Background(), ti.server.Client(), good, ti.issuer(), testAudience, time.Now()); err != nil { - t.Fatalf("Verify with an unusable key in the JWKS = %v, want nil (bad key should be skipped)", err) - } - - referencesSkipped := mintJWT(t, "RS256", "ec-bad", rsaKey, validClaims(ti.issuer())) - if _, err := Verify(context.Background(), ti.server.Client(), referencesSkipped, ti.issuer(), testAudience, time.Now()); err == nil { - t.Fatal("Verify accepted a token whose kid names a key that was skipped") - } -} - -// TestDiscoverKeysAllUnusable pins that an issuer whose JWKS contains only keys the -// verifier can't use fails at discovery (naming the cause) rather than returning an -// empty key set. -func TestDiscoverKeysAllUnusable(t *testing.T) { - ti := newTestIssuer(t) - ti.jwks.Keys = append(ti.jwks.Keys, jwkT{ - KeyType: "EC", KeyID: "ec-bad", EllipticCurve: "P-192", EllipticX: "AA", EllipticY: "AA", - }) - _, err := discoverKeysForIssuer(context.Background(), ti.server.Client(), ti.issuer()) - if err == nil { - t.Fatal("discoverKeysForIssuer returned nil error for an issuer with no usable keys") - } - if !strings.Contains(err.Error(), "no usable keys") { - t.Errorf("error = %q, want it to name the cause (contain %q)", err, "no usable keys") - } -} - -// TestDiscoverKeysEmptyJWKS pins the distinct error for an issuer that publishes no -// keys at all, versus one whose keys are all unusable. -func TestDiscoverKeysEmptyJWKS(t *testing.T) { - ti := newTestIssuer(t) // no keys registered - _, err := discoverKeysForIssuer(context.Background(), ti.server.Client(), ti.issuer()) - if err == nil { - t.Fatal("discoverKeysForIssuer returned nil error for an empty JWKS") - } - if !strings.Contains(err.Error(), "empty JWKS") { - t.Errorf("error = %q, want it to mention %q", err, "empty JWKS") - } -} - -func TestParseJWKRejects(t *testing.T) { - tests := []struct { - name string - jwk jwkT - }{ - {"no key ID", jwkT{KeyType: "RSA", KeyID: ""}}, - {"unknown key type", jwkT{KeyType: "OKP", KeyID: "k"}}, - {"unsupported EC curve", jwkT{KeyType: "EC", KeyID: "k", EllipticCurve: "P-192", EllipticX: "AA", EllipticY: "AA"}}, - {"EC missing coordinate", jwkT{KeyType: "EC", KeyID: "k", EllipticCurve: "P-256", EllipticX: "AA"}}, - {"EC malformed x", jwkT{KeyType: "EC", KeyID: "k", EllipticCurve: "P-256", EllipticX: "!!!", EllipticY: "AA"}}, - {"RSA malformed n", jwkT{KeyType: "RSA", KeyID: "k", RSAN: "!!!", RSAE: "AQAB"}}, - } - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - if _, err := parseJWK(tc.jwk); err == nil { - t.Errorf("parseJWK(%s) = nil, want error", tc.name) - } - }) - } -} - -func TestEllipticCurveForJWK(t *testing.T) { - for _, crv := range []string{"P-256", "P-384", "P-521"} { - if _, err := ellipticCurveForJWK(crv); err != nil { - t.Errorf("ellipticCurveForJWK(%q) = %v, want nil", crv, err) - } - } - if _, err := ellipticCurveForJWK("P-192"); err == nil { - t.Error("ellipticCurveForJWK(P-192) = nil, want error") - } -} diff --git a/cmd/ateapi/internal/oidcauth/oidcauth.go b/cmd/ateapi/internal/oidcauth/oidcauth.go new file mode 100644 index 000000000..654f44c33 --- /dev/null +++ b/cmd/ateapi/internal/oidcauth/oidcauth.go @@ -0,0 +1,128 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Package oidcauth implements pluggable OIDC authentication chaining for the ate-api-server, +// mirroring Kubernetes' structured AuthenticationConfiguration API. +package oidcauth + + +import ( + "context" + "net/http" + "strings" + "time" +) + + + + + +// Authenticator evaluates a Bearer token and returns an authenticated principal ID. +// +// ok == false indicates that the token was not recognized by this authenticator +// (e.g., unrecognized issuer), so the chain should try the next authenticator. +// ok == true indicates that the token was evaluated by this authenticator; if err != nil, +// verification failed (e.g. expired token or invalid signature) and the chain stops. +type Authenticator interface { + AuthenticateToken(ctx context.Context, token string) (id string, ok bool, err error) +} + +// Chain evaluates multiple Authenticators sequentially. +type Chain []Authenticator + +// AuthenticateToken calls each authenticator in sequence. +// The first authenticator whose issuer matches the token evaluates it. +// If verification succeeds, the principal ID is returned with ok=true, err=nil. +// If verification fails, authentication fails immediately with ok=true, err!=nil. +// If no authenticator recognizes the token issuer, ok=false, err=nil is returned. +func (c Chain) AuthenticateToken(ctx context.Context, token string) (string, bool, error) { + for _, auth := range c { + id, ok, err := auth.AuthenticateToken(ctx, token) + if err != nil { + return "", true, err + } + if ok { + return id, true, nil + } + } + return "", false, nil +} + +// OIDCAuthenticatorConfig defines the configuration for an OIDC authenticator, +// mirroring the structured AuthenticationConfiguration API in Kubernetes. +type OIDCAuthenticatorConfig struct { + // IssuerURL is the OIDC issuer URL (e.g., "https://accounts.google.com"). + IssuerURL string + // Audiences is the list of acceptable audience values in the token. + Audiences []string + // UsernameClaim specifies which JWT claim to map to the principal ID ("email" or "sub"). + // If empty or if the claim is absent in the token, it falls back to the "sub" claim. + UsernameClaim string + // UsernamePrefix is prepended to the extracted username claim (if non-empty). + UsernamePrefix string +} + +// OIDCAuthenticator implements Authenticator for a specific OIDC issuer. +type OIDCAuthenticator struct { + cfg OIDCAuthenticatorConfig + httpClient *http.Client + now func() time.Time +} + +// New creates a new OIDCAuthenticator. +func New(cfg OIDCAuthenticatorConfig, httpClient *http.Client) *OIDCAuthenticator { + return &OIDCAuthenticator{ + cfg: cfg, + httpClient: httpClient, + now: time.Now, + } +} + +// AuthenticateToken verifies the Bearer token against this authenticator's configured issuer and audiences. +func (a *OIDCAuthenticator) AuthenticateToken(ctx context.Context, token string) (string, bool, error) { + + if a.cfg.IssuerURL == "" { + return "", false, nil + } + + claims, err := Verify(ctx, a.httpClient, token, a.cfg.IssuerURL, a.cfg.Audiences, a.now()) + + + if err != nil { + // If verification failed because of unexpected issuer, return ok=false so chain can continue. + if isIssuerMismatch(err) { + return "", false, nil + } + return "", true, err + } + + username := claims.Subject + if a.cfg.UsernameClaim == "email" && claims.Email != "" { + username = claims.Email + } + + if a.cfg.UsernamePrefix != "" { + username = a.cfg.UsernamePrefix + username + } + + return username, true, nil +} + +func isIssuerMismatch(err error) bool { + if err == nil { + return false + } + return strings.HasPrefix(err.Error(), "unexpected issuer") +} + diff --git a/cmd/ateapi/internal/oidcauth/oidcauth_test.go b/cmd/ateapi/internal/oidcauth/oidcauth_test.go new file mode 100644 index 000000000..42f73a0b3 --- /dev/null +++ b/cmd/ateapi/internal/oidcauth/oidcauth_test.go @@ -0,0 +1,311 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package oidcauth + + +import ( + "context" + "crypto" + "crypto/rand" + "crypto/rsa" + + "crypto/sha256" + "encoding/base64" + "encoding/json" + "math/big" + "net/http" + "net/http/httptest" + "testing" + "time" +) + +func b64url(b []byte) string { return base64.RawURLEncoding.EncodeToString(b) } + +type jwkSetT struct { + Keys []jwkT `json:"keys"` +} + +type jwkT struct { + KeyType string `json:"kty"` + KeyID string `json:"kid,omitempty"` + RSAN string `json:"n"` + RSAE string `json:"e"` +} + +type testIssuer struct { + server *httptest.Server + jwks jwkSetT + rsaKey *rsa.PrivateKey +} + +func newTestIssuer(t *testing.T) *testIssuer { + t.Helper() + ti := &testIssuer{} + key, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + t.Fatalf("GenerateKey: %v", err) + } + ti.rsaKey = key + + ti.jwks.Keys = append(ti.jwks.Keys, jwkT{ + KeyType: "RSA", + KeyID: "key-1", + RSAN: b64url(key.N.Bytes()), + RSAE: b64url(big.NewInt(int64(key.E)).Bytes()), + }) + + mux := http.NewServeMux() + mux.HandleFunc("/.well-known/openid-configuration", func(w http.ResponseWriter, _ *http.Request) { + doc := map[string]string{"jwks_uri": ti.server.URL + "/jwks"} + _ = json.NewEncoder(w).Encode(doc) + }) + mux.HandleFunc("/jwks", func(w http.ResponseWriter, _ *http.Request) { + _ = json.NewEncoder(w).Encode(ti.jwks) + }) + ti.server = httptest.NewServer(mux) + t.Cleanup(ti.server.Close) + return ti +} + +func (ti *testIssuer) issuer() string { return ti.server.URL } + +func (ti *testIssuer) mintJWT(t *testing.T, claims map[string]any) string { + t.Helper() + header := map[string]string{"alg": "RS256", "typ": "JWT", "kid": "key-1"} + hb, _ := json.Marshal(header) + cb, _ := json.Marshal(claims) + signingInput := b64url(hb) + "." + b64url(cb) + + digest := sha256.Sum256([]byte(signingInput)) + sig, err := rsa.SignPKCS1v15(rand.Reader, ti.rsaKey, crypto.SHA256, digest[:]) + + if err != nil { + t.Fatalf("signing RSA: %v", err) + } + return signingInput + "." + b64url(sig) +} + +func TestOIDCAuthenticator_SubClaim(t *testing.T) { + ti := newTestIssuer(t) + now := time.Now() + tok := ti.mintJWT(t, map[string]any{ + "iss": ti.issuer(), + "sub": "user-123", + "aud": "test-aud", + "exp": now.Add(time.Hour).Unix(), + }) + + auth := New(OIDCAuthenticatorConfig{ + IssuerURL: ti.issuer(), + Audiences: []string{"test-aud"}, + UsernameClaim: "sub", + }, ti.server.Client()) + + id, ok, err := auth.AuthenticateToken(context.Background(), tok) + if err != nil { + t.Fatalf("AuthenticateToken() err = %v, want nil", err) + } + if !ok { + t.Fatalf("AuthenticateToken() ok = false, want true") + } + if id != "user-123" { + t.Errorf("id = %q, want %q", id, "user-123") + } +} + +func TestOIDCAuthenticator_EmailClaimWithPrefix(t *testing.T) { + ti := newTestIssuer(t) + now := time.Now() + tok := ti.mintJWT(t, map[string]any{ + "iss": ti.issuer(), + "sub": "user-123", + "email": "shrutinair@google.com", + "aud": "test-aud", + "exp": now.Add(time.Hour).Unix(), + }) + + auth := New(OIDCAuthenticatorConfig{ + IssuerURL: ti.issuer(), + Audiences: []string{"test-aud"}, + UsernameClaim: "email", + UsernamePrefix: "google:", + }, ti.server.Client()) + + id, ok, err := auth.AuthenticateToken(context.Background(), tok) + if err != nil { + t.Fatalf("AuthenticateToken() err = %v, want nil", err) + } + if !ok { + t.Fatalf("AuthenticateToken() ok = false, want true") + } + if id != "google:shrutinair@google.com" { + t.Errorf("id = %q, want %q", id, "google:shrutinair@google.com") + } +} + +func TestChain_AuthenticateToken(t *testing.T) { + ti1 := newTestIssuer(t) + ti2 := newTestIssuer(t) + now := time.Now() + + tok2 := ti2.mintJWT(t, map[string]any{ + "iss": ti2.issuer(), + "sub": "sub-2", + "email": "dev@example.com", + "aud": "aud-2", + "exp": now.Add(time.Hour).Unix(), + }) + + chain := Chain{ + New(OIDCAuthenticatorConfig{ + IssuerURL: ti1.issuer(), + Audiences: []string{"aud-1"}, + UsernameClaim: "sub", + }, ti1.server.Client()), + New(OIDCAuthenticatorConfig{ + IssuerURL: ti2.issuer(), + Audiences: []string{"aud-2"}, + UsernameClaim: "email", + }, ti2.server.Client()), + } + + id, ok, err := chain.AuthenticateToken(context.Background(), tok2) + if err != nil { + t.Fatalf("chain.AuthenticateToken() err = %v, want nil", err) + } + if !ok { + t.Fatalf("chain.AuthenticateToken() ok = false, want true") + } + if id != "dev@example.com" { + t.Errorf("id = %q, want %q", id, "dev@example.com") + } +} + +func TestChain_VerificationFailureStopsChain(t *testing.T) { + ti1 := newTestIssuer(t) + now := time.Now() + + // Token issued by ti1 but expired + tok := ti1.mintJWT(t, map[string]any{ + "iss": ti1.issuer(), + "sub": "sub-1", + "aud": "aud-1", + "exp": now.Add(-time.Hour).Unix(), + }) + + chain := Chain{ + New(OIDCAuthenticatorConfig{ + IssuerURL: ti1.issuer(), + Audiences: []string{"aud-1"}, + UsernameClaim: "sub", + }, ti1.server.Client()), + } + + _, ok, err := chain.AuthenticateToken(context.Background(), tok) + if err == nil { + t.Fatalf("chain.AuthenticateToken() err = nil, want expired token error") + } + if !ok { + t.Errorf("chain.AuthenticateToken() ok = false, want true (issuer matched)") + } +} + +func TestChain_BothKubernetesAndOIDC(t *testing.T) { + k8sIssuer := newTestIssuer(t) + oidcIssuer := newTestIssuer(t) + unknownIssuer := newTestIssuer(t) + now := time.Now() + + // Build the exact authenticator chain used by ate-api-server in main.go + chain := Chain{ + // 1. Kubernetes Bound ServiceAccount Authenticator + New(OIDCAuthenticatorConfig{ + IssuerURL: k8sIssuer.issuer(), + Audiences: []string{"api.ate-system.svc"}, + UsernameClaim: "sub", + }, k8sIssuer.server.Client()), + // 2. Human Google IDP Authenticator + New(OIDCAuthenticatorConfig{ + IssuerURL: oidcIssuer.issuer(), + Audiences: []string{"32555940559.apps.googleusercontent.com"}, + UsernameClaim: "email", + UsernamePrefix: "google:", + }, oidcIssuer.server.Client()), + } + + + // Test Case 1: Kubernetes ServiceAccount token (issued by K8s) + k8sTok := k8sIssuer.mintJWT(t, map[string]any{ + "iss": k8sIssuer.issuer(), + "sub": "system:serviceaccount:ate-system:atelet", + "aud": "api.ate-system.svc", + "exp": now.Add(time.Hour).Unix(), + "kubernetes.io": map[string]any{ + "namespace": "ate-system", + "serviceaccount": map[string]any{ + "name": "atelet", + "uid": "sa-uid-123", + }, + }, + }) + + idK8s, ok, err := chain.AuthenticateToken(context.Background(), k8sTok) + if err != nil { + t.Fatalf("chain.AuthenticateToken(k8sTok) err = %v, want nil", err) + } + if !ok { + t.Fatalf("chain.AuthenticateToken(k8sTok) ok = false, want true") + } + if idK8s != "system:serviceaccount:ate-system:atelet" { + t.Errorf("idK8s = %q, want %q", idK8s, "system:serviceaccount:ate-system:atelet") + } + + // Test Case 2: Human OIDC token (issued by Google IDP) + oidcTok := oidcIssuer.mintJWT(t, map[string]any{ + "iss": oidcIssuer.issuer(), + "sub": "114973134352974025410", + "email": "shrutinair@google.com", + "aud": "32555940559.apps.googleusercontent.com", + "exp": now.Add(time.Hour).Unix(), + }) + + idOIDC, ok, err := chain.AuthenticateToken(context.Background(), oidcTok) + if err != nil { + t.Fatalf("chain.AuthenticateToken(oidcTok) err = %v, want nil", err) + } + if !ok { + t.Fatalf("chain.AuthenticateToken(oidcTok) ok = false, want true") + } + if idOIDC != "google:shrutinair@google.com" { + t.Errorf("idOIDC = %q, want %q", idOIDC, "google:shrutinair@google.com") + } + + // Test Case 3: Token from an unrecognized third issuer -> skipped (ok=false) + unknownTok := unknownIssuer.mintJWT(t, map[string]any{ + "iss": unknownIssuer.issuer(), + "sub": "some-user", + "aud": "some-aud", + "exp": now.Add(time.Hour).Unix(), + }) + + _, ok, err = chain.AuthenticateToken(context.Background(), unknownTok) + if err != nil { + t.Fatalf("chain.AuthenticateToken(unknownTok) err = %v, want nil", err) + } + if ok { + t.Errorf("chain.AuthenticateToken(unknownTok) ok = true, want false (unrecognized issuer)") + } +} + diff --git a/cmd/ateapi/internal/oidcauth/verifier.go b/cmd/ateapi/internal/oidcauth/verifier.go new file mode 100644 index 000000000..4af08a34b --- /dev/null +++ b/cmd/ateapi/internal/oidcauth/verifier.go @@ -0,0 +1,533 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package oidcauth + +import ( + "context" + "crypto" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rsa" + "encoding/base64" + "encoding/json" + "fmt" + "io" + "log/slog" + "math/big" + "net/http" + "net/url" + "strings" + "sync" + "time" +) + +// KeyAndID wraps a crypto.PublicKey along with the key ID that will identify it during +// the verification process. +type KeyAndID struct { + KeyID string + PublicKey crypto.PublicKey +} + +type parseHeader struct { + Type string `json:"typ,omitempty"` + Algorithm string `json:"alg,omitempty"` + KeyID string `json:"kid,omitempty"` +} + +type parseClaims struct { + // Claims from RFC7519 + Issuer string `json:"iss,omitempty"` + Subject string `json:"sub,omitempty"` + Audiences json.RawMessage `json:"aud,omitempty"` + Expiration float64 `json:"exp,omitempty"` + NotBefore float64 `json:"nbf,omitempty"` + IssuedAt float64 `json:"iat,omitempty"` + JTI string `json:"jti,omitempty"` + Email string `json:"email,omitempty"` + + // Kubernetes bound token claims. + BoundClaims parseBoundClaims `json:"kubernetes.io,omitempty"` +} + +type parseBoundClaims struct { + Namespace string `json:"namespace,omitempty"` + Pod parseBoundObjectReference `json:"pod,omitempty"` + ServiceAccount parseBoundObjectReference `json:"serviceaccount,omitempty"` + Secret parseBoundObjectReference `json:"secret,omitempty"` + Node parseBoundObjectReference `json:"node,omitempty"` + WarnAfter float64 `json:"warnafter,omitempty"` +} + +type parseBoundObjectReference struct { + Name string `json:"name,omitempty"` + UID string `json:"uid,omitempty"` +} + +// OIDCClaims covers standard RFC7519/OIDC claims as well as optional Kubernetes bound claims. +type OIDCClaims struct { + // Mandatory OIDC Specification Claims (RFC 7519 / OIDC Core 1.0) + Issuer string + Subject string + Audiences []string + Expiration time.Time + IssuedAt time.Time + + // Optional Standard Claims + Email string + NotBefore time.Time + JTI string + + // Kubernetes contains structured bound token metadata when present (nil for non-Kubernetes tokens). + Kubernetes *KubernetesBoundClaims +} + +// KubernetesBoundClaims contains metadata from a Kubernetes ServiceAccount token ("kubernetes.io" claim). +type KubernetesBoundClaims struct { + Namespace string + ServiceAccountName string + ServiceAccountUID string + PodName string + PodUID string + SecretName string + SecretUID string + NodeName string + NodeUID string + WarnAfter time.Time +} + +var ( + permittedSkew = 5 * time.Minute + defaultHTTPClient = &http.Client{Timeout: 10 * time.Second} +) + +// Verify verifies and extracts claims from a Kubernetes or external IDP OIDC JWT. +func Verify(ctx context.Context, httpClient *http.Client, jwt string, expectedIssuer string, expectedAudiences []string, now time.Time) (*OIDCClaims, error) { + segments := strings.Split(jwt, ".") + if len(segments) != 3 { + return nil, fmt.Errorf("malformed JWT") + } + + headerBytes, err := base64.RawURLEncoding.DecodeString(segments[0]) + if err != nil { + return nil, fmt.Errorf("while base64 decoding header: %w", err) + } + + signatureBytes, err := base64.RawURLEncoding.DecodeString(segments[2]) + if err != nil { + return nil, fmt.Errorf("while base64 decoding signature: %w", err) + } + + var header parseHeader + if err := json.Unmarshal(headerBytes, &header); err != nil { + return nil, fmt.Errorf("while unmarshaling header: %w", err) + } + + // RFC 7519 section 5.2 states that typ claims are case insensitive. + // RFC 7519 section 5.2 states that the "JWT" typ claim MAY be omitted. + // If present, it MUST be "JWT" or "application/jwt". + typ := strings.ToLower(header.Type) + if typ != "" && typ != "jwt" && typ != "application/jwt" { + return nil, fmt.Errorf("unexpected value in type header") + } + + if httpClient == nil { + httpClient = defaultHTTPClient + } + + payloadBytes, err := base64.RawURLEncoding.DecodeString(segments[1]) + if err != nil { + return nil, fmt.Errorf("while base64-decoding payload: %w", err) + } + + var rawClaims parseClaims + if err := json.Unmarshal(payloadBytes, &rawClaims); err != nil { + return nil, fmt.Errorf("while unmarshaling payload: %w", err) + } + + if !issuerMatches(rawClaims.Issuer, expectedIssuer) { + return nil, fmt.Errorf("unexpected issuer %q", rawClaims.Issuer) + } + + keys, err := discoverKeysForIssuer(ctx, httpClient, expectedIssuer) + if err != nil { + return nil, fmt.Errorf("while discovering keys from issuer: %w", err) + } + + if header.KeyID == "" { + return nil, fmt.Errorf("key ID is required") + } + + selectedKey, ok := keys[header.KeyID] + if !ok { + return nil, fmt.Errorf("unknown key ID %q", header.KeyID) + } + + toBeSignedBytes := []byte(fmt.Sprintf("%s.%s", segments[0], segments[1])) + + if err := verifySignature(header.Algorithm, selectedKey, toBeSignedBytes, signatureBytes); err != nil { + return nil, fmt.Errorf("while verifying JWT signature: %w", err) + } + + audiences, err := extractAudiences(rawClaims.Audiences) + if err != nil { + return nil, fmt.Errorf("unable to parse audiences") + } + + if len(expectedAudiences) == 0 { + return nil, fmt.Errorf("at least one expected audience is required") + } + + matchedAudience := false + for _, expectedAudience := range expectedAudiences { + for _, audience := range audiences { + if audience == expectedAudience { + matchedAudience = true + break + } + } + if matchedAudience { + break + } + } + if !matchedAudience { + return nil, fmt.Errorf("token is not issued for expected audience") + } + + expiration := time.Unix(int64(rawClaims.Expiration), 0) + notBefore := time.Unix(int64(rawClaims.NotBefore), 0) + issuedAt := time.Unix(int64(rawClaims.IssuedAt), 0) + + if expiration.Before(now.Add(-permittedSkew)) { + return nil, fmt.Errorf("jwt has expired") + } + + if notBefore.After(now.Add(permittedSkew)) { + return nil, fmt.Errorf("jwt is not valid yet") + } + + if issuedAt.After(now.Add(permittedSkew)) { + return nil, fmt.Errorf("jwt claims to have been issued in the future") + } + + var k8sClaims *KubernetesBoundClaims + if rawClaims.BoundClaims.Namespace != "" || rawClaims.BoundClaims.ServiceAccount.Name != "" || + rawClaims.BoundClaims.Pod.Name != "" || rawClaims.BoundClaims.Node.Name != "" || + rawClaims.BoundClaims.Secret.Name != "" { + k8sClaims = &KubernetesBoundClaims{ + Namespace: rawClaims.BoundClaims.Namespace, + ServiceAccountName: rawClaims.BoundClaims.ServiceAccount.Name, + ServiceAccountUID: rawClaims.BoundClaims.ServiceAccount.UID, + PodName: rawClaims.BoundClaims.Pod.Name, + PodUID: rawClaims.BoundClaims.Pod.UID, + SecretName: rawClaims.BoundClaims.Secret.Name, + SecretUID: rawClaims.BoundClaims.Secret.UID, + NodeName: rawClaims.BoundClaims.Node.Name, + NodeUID: rawClaims.BoundClaims.Node.UID, + WarnAfter: time.Unix(int64(rawClaims.BoundClaims.WarnAfter), 0), + } + } + + return &OIDCClaims{ + Issuer: rawClaims.Issuer, + Audiences: audiences, + Subject: rawClaims.Subject, + Email: rawClaims.Email, + Expiration: expiration, + NotBefore: notBefore, + IssuedAt: issuedAt, + JTI: rawClaims.JTI, + Kubernetes: k8sClaims, + }, nil +} + +func verifySignature(algorithm string, selectedKey crypto.PublicKey, toBeSignedBytes, signatureBytes []byte) error { + switch algorithm { + case "RS256": + rsaKey, ok := selectedKey.(*rsa.PublicKey) + if !ok { + return fmt.Errorf("requested key ID is not an RSA key") + } + digest := crypto.SHA256.New() + digest.Write(toBeSignedBytes) + if err := rsa.VerifyPKCS1v15(rsaKey, crypto.SHA256, digest.Sum(nil), signatureBytes); err != nil { + return fmt.Errorf("while validating RSA PKCS1v15 signature: %w", err) + } + case "RS384": + rsaKey, ok := selectedKey.(*rsa.PublicKey) + if !ok { + return fmt.Errorf("requested key ID is not an RSA key") + } + digest := crypto.SHA384.New() + digest.Write(toBeSignedBytes) + if err := rsa.VerifyPKCS1v15(rsaKey, crypto.SHA384, digest.Sum(nil), signatureBytes); err != nil { + return fmt.Errorf("while validating RSA PKCS1v15 signature: %w", err) + } + case "RS512": + rsaKey, ok := selectedKey.(*rsa.PublicKey) + if !ok { + return fmt.Errorf("requested key ID is not an RSA key") + } + digest := crypto.SHA512.New() + digest.Write(toBeSignedBytes) + if err := rsa.VerifyPKCS1v15(rsaKey, crypto.SHA512, digest.Sum(nil), signatureBytes); err != nil { + return fmt.Errorf("while validating RSA PKCS1v15 signature: %w", err) + } + case "ES256": + ecKey, ok := selectedKey.(*ecdsa.PublicKey) + if !ok { + return fmt.Errorf("requested key ID is not an ECDSA P256 key") + } + r, s, err := parseECDSASignature(signatureBytes) + if err != nil { + return fmt.Errorf("invalid ecdsa signature") + } + digest := crypto.SHA256.New() + digest.Write(toBeSignedBytes) + if !ecdsa.Verify(ecKey, digest.Sum(nil), r, s) { + return fmt.Errorf("invalid ecdsa signature") + } + case "ES384": + ecKey, ok := selectedKey.(*ecdsa.PublicKey) + if !ok { + return fmt.Errorf("requested key ID is not an ECDSA P384 key") + } + r, s, err := parseECDSASignature(signatureBytes) + if err != nil { + return fmt.Errorf("invalid ecdsa signature") + } + digest := crypto.SHA384.New() + digest.Write(toBeSignedBytes) + if !ecdsa.Verify(ecKey, digest.Sum(nil), r, s) { + return fmt.Errorf("invalid ecdsa signature") + } + case "ES512": + ecKey, ok := selectedKey.(*ecdsa.PublicKey) + if !ok { + return fmt.Errorf("requested key ID is not an ECDSA P521 key") + } + r, s, err := parseECDSASignature(signatureBytes) + if err != nil { + return fmt.Errorf("invalid ecdsa signature") + } + digest := crypto.SHA512.New() + digest.Write(toBeSignedBytes) + if !ecdsa.Verify(ecKey, digest.Sum(nil), r, s) { + return fmt.Errorf("invalid ecdsa signature") + } + default: + return fmt.Errorf("unsupported algorithm %q", algorithm) + } + return nil +} + +func parseECDSASignature(sig []byte) (*big.Int, *big.Int, error) { + if len(sig)%2 != 0 { + return nil, nil, fmt.Errorf("ECDSA signature length must be even") + } + half := len(sig) / 2 + r := new(big.Int).SetBytes(sig[:half]) + s := new(big.Int).SetBytes(sig[half:]) + return r, s, nil +} + +func extractAudiences(rawAud json.RawMessage) ([]string, error) { + if len(rawAud) == 0 { + return nil, nil + } + var singleAud string + if err := json.Unmarshal(rawAud, &singleAud); err == nil { + return []string{singleAud}, nil + } + var multiAud []string + if err := json.Unmarshal(rawAud, &multiAud); err == nil { + return multiAud, nil + } + return nil, fmt.Errorf("invalid audience format") +} + +var ( + keysCacheLock sync.RWMutex + keysCache = make(map[string]map[string]crypto.PublicKey) +) + +func discoverKeysForIssuer(ctx context.Context, httpClient *http.Client, issuer string) (map[string]crypto.PublicKey, error) { + keysCacheLock.RLock() + keys, ok := keysCache[issuer] + keysCacheLock.RUnlock() + if ok { + return keys, nil + } + + keysCacheLock.Lock() + defer keysCacheLock.Unlock() + + if keys, ok := keysCache[issuer]; ok { + return keys, nil + } + + issuerURL := issuer + if !strings.HasPrefix(issuerURL, "http://") && !strings.HasPrefix(issuerURL, "https://") { + issuerURL = "https://" + issuerURL + } + + discURL, err := url.JoinPath(issuerURL, "/.well-known/openid-configuration") + if err != nil { + return nil, fmt.Errorf("invalid issuer URL: %w", err) + } + + var discoveryDoc struct { + JWKSURI string `json:"jwks_uri"` + } + if err := fetchJSON(ctx, httpClient, discURL, &discoveryDoc); err != nil { + return nil, fmt.Errorf("while fetching OIDC Discovery document: %w", err) + } + slog.InfoContext(ctx, "Fetched discovery doc", slog.Any("doc", discoveryDoc)) + + var jwkSet struct { + Keys []struct { + KeyType string `json:"kty"` + KeyID string `json:"kid"` + EllipticCurve string `json:"crv"` + EllipticX string `json:"x"` + EllipticY string `json:"y"` + RSAN string `json:"n"` + RSAE string `json:"e"` + } `json:"keys"` + } + if err := fetchJSON(ctx, httpClient, discoveryDoc.JWKSURI, &jwkSet); err != nil { + return nil, fmt.Errorf("while fetching JWKS: %w", err) + } + slog.InfoContext(ctx, "Fetched JWK set", slog.Any("jwkSet", fmt.Sprintf("%+v", jwkSet))) + + keys = make(map[string]crypto.PublicKey) + var skipped int + for _, jwk := range jwkSet.Keys { + pubKey, err := parseJWK(jwk.KeyType, jwk.KeyID, jwk.EllipticCurve, jwk.EllipticX, jwk.EllipticY, jwk.RSAN, jwk.RSAE) + if err != nil { + skipped++ + slog.WarnContext(ctx, "Skipping unusable JWK", slog.String("kid", jwk.KeyID), slog.Any("err", err)) + continue + } + keys[jwk.KeyID] = pubKey + } + + if len(keys) == 0 { + if len(jwkSet.Keys) == 0 { + return nil, fmt.Errorf("issuer %q published an empty JWKS", issuer) + } + return nil, fmt.Errorf("no usable keys in JWKS for issuer %q (%d skipped)", issuer, skipped) + } + + keysCache[issuer] = keys + return keys, nil +} + +func parseJWK(kty, kid, crv, x, y, n, e string) (crypto.PublicKey, error) { + if kid == "" { + return nil, fmt.Errorf("JWK has no key ID") + } + + switch kty { + case "EC": + curve, err := ellipticCurveForJWK(crv) + if err != nil { + return nil, err + } + if x == "" || y == "" { + return nil, fmt.Errorf("EC JWK is missing the x or y coordinate") + } + xb, err := base64.RawURLEncoding.DecodeString(x) + if err != nil { + return nil, fmt.Errorf("while base64-decoding EC x coordinate: %w", err) + } + yb, err := base64.RawURLEncoding.DecodeString(y) + if err != nil { + return nil, fmt.Errorf("while base64-decoding EC y coordinate: %w", err) + } + xInt := new(big.Int).SetBytes(xb) + yInt := new(big.Int).SetBytes(yb) + + if !curve.IsOnCurve(xInt, yInt) { + return nil, fmt.Errorf("EC JWK coordinate is out of range for curve %q", crv) + } + return &ecdsa.PublicKey{Curve: curve, X: xInt, Y: yInt}, nil + + case "RSA": + nb, err := base64.RawURLEncoding.DecodeString(n) + if err != nil { + return nil, fmt.Errorf("while base64-decoding n: %w", err) + } + eb, err := base64.RawURLEncoding.DecodeString(e) + if err != nil { + return nil, fmt.Errorf("while base64-decoding e: %w", err) + } + eInt := new(big.Int).SetBytes(eb) + return &rsa.PublicKey{N: new(big.Int).SetBytes(nb), E: int(eInt.Int64())}, nil + + default: + return nil, fmt.Errorf("unhandled key type %q", kty) + } +} + +func ellipticCurveForJWK(crv string) (elliptic.Curve, error) { + switch crv { + case "P-256": + return elliptic.P256(), nil + case "P-384": + return elliptic.P384(), nil + case "P-521": + return elliptic.P521(), nil + default: + return nil, fmt.Errorf("unhandled elliptic curve %q", crv) + } +} + +func fetchJSON(ctx context.Context, httpClient *http.Client, urlStr string, target any) error { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, urlStr, nil) + if err != nil { + return fmt.Errorf("while constructing HTTP request: %w", err) + } + + resp, err := httpClient.Do(req) + if err != nil { + return fmt.Errorf("while making HTTP request: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("non-200 response code %d", resp.StatusCode) + } + + bodyBytes, err := io.ReadAll(resp.Body) + if err != nil { + return fmt.Errorf("while reading response body: %w", err) + } + + if err := json.Unmarshal(bodyBytes, target); err != nil { + return fmt.Errorf("while unmarshaling response body: %w", err) + } + + return nil +} + +func issuerMatches(actual, expected string) bool { + if actual == expected { + return true + } + // Strip "https://" scheme for normalization comparisons (e.g. Google IDP) + actNorm := strings.TrimPrefix(actual, "https://") + expNorm := strings.TrimPrefix(expected, "https://") + return actNorm == expNorm +} diff --git a/cmd/ateapi/internal/sessionidentity/sessionidentity.go b/cmd/ateapi/internal/sessionidentity/sessionidentity.go index a9a581271..c37fd5066 100644 --- a/cmd/ateapi/internal/sessionidentity/sessionidentity.go +++ b/cmd/ateapi/internal/sessionidentity/sessionidentity.go @@ -28,7 +28,9 @@ import ( "strings" "time" - "github.com/agent-substrate/substrate/cmd/ateapi/internal/k8sjwt" + "github.com/agent-substrate/substrate/cmd/ateapi/internal/oidcauth" + + "github.com/agent-substrate/substrate/cmd/ateapi/internal/sessionidjwt" "github.com/agent-substrate/substrate/internal/localca" "github.com/agent-substrate/substrate/internal/localjwtauthority" @@ -44,8 +46,8 @@ import ( type Server struct { ateapipb.UnimplementedSessionIdentityServer - clientJWTIssuer string - clientJWTAudience string + k8sJWTIssuer string + k8sJWTAudience string // TODO: Cache the signing keys in memory, so we don't read from a file every time. sessionIDJWTPoolFile string @@ -57,10 +59,10 @@ type Server struct { var _ ateapipb.SessionIdentityServer = (*Server)(nil) -func New(clientJWTIssuer, clientJWTAudience, sessionIDJWTPoolFile, sessionIDCAPoolFile, workerCACerts string, httpClient *http.Client) *Server { +func New(k8sJWTIssuer, k8sJWTAudience, sessionIDJWTPoolFile, sessionIDCAPoolFile, workerCACerts string, httpClient *http.Client) *Server { return &Server{ - clientJWTIssuer: clientJWTIssuer, - clientJWTAudience: clientJWTAudience, + k8sJWTIssuer: k8sJWTIssuer, + k8sJWTAudience: k8sJWTAudience, sessionIDJWTPoolFile: sessionIDJWTPoolFile, sessionIDCAPoolFile: sessionIDCAPoolFile, workerCACerts: workerCACerts, @@ -81,7 +83,9 @@ func (s *Server) MintJWT(ctx context.Context, req *ateapipb.MintJWTRequest) (*at clientJWT := strings.TrimPrefix(authorization[0], "Bearer ") - clientClaims, err := k8sjwt.Verify(ctx, s.httpClient, clientJWT, s.clientJWTIssuer, s.clientJWTAudience, time.Now()) + clientClaims, err := oidcauth.Verify(ctx, s.httpClient, clientJWT, s.k8sJWTIssuer, []string{s.k8sJWTAudience}, time.Now()) + + if err != nil { slog.ErrorContext(ctx, "Error while verifying client JWT", slog.Any("err", err)) return nil, status.Errorf(codes.Unauthenticated, "Unauthenticated") @@ -89,6 +93,7 @@ func (s *Server) MintJWT(ctx context.Context, req *ateapipb.MintJWTRequest) (*at slog.InfoContext(ctx, "Verified client JWT", slog.Any("claims", clientClaims)) + // TODO: Extract K8s identity from incoming JWT // TODO: Cross-check requested session and user claims against the session database. diff --git a/cmd/ateapi/main.go b/cmd/ateapi/main.go index b7bc65472..dfbdcb920 100644 --- a/cmd/ateapi/main.go +++ b/cmd/ateapi/main.go @@ -31,8 +31,10 @@ import ( "github.com/agent-substrate/substrate/cmd/ateapi/internal/controlapi" "github.com/agent-substrate/substrate/cmd/ateapi/internal/debugapi" - "github.com/agent-substrate/substrate/cmd/ateapi/internal/k8sjwt" + "github.com/agent-substrate/substrate/cmd/ateapi/internal/oidcauth" "github.com/agent-substrate/substrate/cmd/ateapi/internal/sessionidentity" + + "github.com/agent-substrate/substrate/cmd/ateapi/internal/store/ateredis" "github.com/agent-substrate/substrate/cmd/ateapi/internal/workercache" "github.com/agent-substrate/substrate/internal/ateapiauth" @@ -71,8 +73,13 @@ var ( redisTLSServerName = pflag.String("redis-tls-server-name", "", "The ServerName to use for Redis TLS hostname verification.") redisClientCert = pflag.String("redis-client-cert", "", "The file containing client TLS certificate/key credential bundle for Redis/Valkey.") - clientJWTIssuer = pflag.String("client-jwt-issuer", "", "The expected issuer URL for client JWTs.") - clientJWTAudience = pflag.String("client-jwt-audience", "", "The expected audience for client JWTs.") + k8sJWTIssuer = pflag.String("k8s-jwt-issuer", "", "The expected issuer URL for in-cluster Kubernetes ServiceAccount JWTs.") + k8sJWTAudience = pflag.String("k8s-jwt-audience", "", "The expected audience for in-cluster Kubernetes ServiceAccount JWTs.") + k8sJWTCAFile = pflag.String("k8s-jwt-ca-cert", ateapiauth.DefaultServiceAccountCAFile, "CA cert file used to verify TLS when fetching the OIDC discovery document and JWKS for k8s JWT authentication. Defaults to the in-cluster service account CA.") + + humanJWTIssuer = pflag.String("human-jwt-issuer", "https://accounts.google.com", "The expected issuer URL for human user JWTs (e.g. Google IDP).") + humanJWTAudience = pflag.String("human-jwt-audience", "32555940559.apps.googleusercontent.com", "The expected audience for human user JWTs (e.g. gcloud auth print-identity-token client ID).") + sessionIDJWTPoolFile = pflag.String("session-id-jwt-pool", "", "The file that contains the serialized JWT authority pool for signing session JWTs") sessionIDCAPoolFile = pflag.String("session-id-ca-pool", "", "The file that contains the CA pool for signing session JWTs") @@ -82,10 +89,10 @@ var ( drainDelay = pflag.Duration("drain-delay", 13*time.Second, "How long to keep accepting new work after SIGTERM, before starting the gRPC drain.") drainTimeout = pflag.Duration("drain-timeout", 15*time.Second, "Deadline for the graceful gRPC drain on shutdown. In-flight RPCs still running past it are forcefully cancelled.") - showVersion = pflag.Bool("version", false, "Print version and exit.") - clientJWTCAFile = pflag.String("client-jwt-ca-cert", ateapiauth.DefaultServiceAccountCAFile, "CA cert file used to verify TLS when fetching the OIDC discovery document and JWKS for JWT authentication. Defaults to the in-cluster service account CA.") + showVersion = pflag.Bool("version", false, "Print version and exit.") ) + func main() { pflag.Parse() if *showVersion { @@ -169,9 +176,9 @@ func main() { ateletDialer := controlapi.NewAteletDialer(workerPodInformer.GetIndexer(), ateletPodInformer.GetIndexer(), *ateletClientCredBundle, *podIdentityCACerts) sm := controlapi.NewService(redisPersistence, workerCache, actorTemplateLister, workerPoolLister, sandboxConfigLister, ateletDialer, clientset) - jwtIssuerDiscoveryClient := buildK8sServiceAccountIssuerDiscoveryClient(ctx, *clientJWTCAFile, *clientJWTIssuer) + jwtIssuerDiscoveryClient := buildK8sServiceAccountIssuerDiscoveryClient(ctx, *k8sJWTCAFile, *k8sJWTIssuer) - sessionIdentitySrv := sessionidentity.New(*clientJWTIssuer, *clientJWTAudience, *sessionIDJWTPoolFile, *sessionIDCAPoolFile, *podIdentityCACerts, jwtIssuerDiscoveryClient) + sessionIdentitySrv := sessionidentity.New(*k8sJWTIssuer, *k8sJWTAudience, *sessionIDJWTPoolFile, *sessionIDCAPoolFile, *podIdentityCACerts, jwtIssuerDiscoveryClient) debugSrv := debugapi.NewService(redisPersistence) lisCfg := &net.ListenConfig{} @@ -180,16 +187,50 @@ func main() { serverboot.Fatal(ctx, "Failed to start listener", err) } + // TODO: Support structured authentication configuration from a file (--authentication-config), + // mirroring Kubernetes' apiserver.config.k8s.io/v1 AuthenticationConfiguration API, + // to dynamically populate authChain from a YAML config instead of hardcoded CLI flags. + var authChain oidcauth.Chain + + if *k8sJWTIssuer != "" { + k8sAuds := []string{} + if *k8sJWTAudience != "" { + k8sAuds = append(k8sAuds, *k8sJWTAudience) + } + authChain = append(authChain, oidcauth.New(oidcauth.OIDCAuthenticatorConfig{ + IssuerURL: *k8sJWTIssuer, + Audiences: k8sAuds, + UsernameClaim: "sub", + }, jwtIssuerDiscoveryClient)) + } + if *humanJWTIssuer != "" { + humanAuds := []string{} + if *humanJWTAudience != "" { + humanAuds = append(humanAuds, *humanJWTAudience) + } + authChain = append(authChain, oidcauth.New(oidcauth.OIDCAuthenticatorConfig{ + IssuerURL: *humanJWTIssuer, + Audiences: humanAuds, + UsernameClaim: "email", + }, nil)) + } + + + authCfg := ateapiauth.ServerConfig{ VerifyBearerToken: func(ctx context.Context, bearer string) (string, error) { - claims, err := k8sjwt.Verify(ctx, jwtIssuerDiscoveryClient, bearer, *clientJWTIssuer, *clientJWTAudience, time.Now()) + id, ok, err := authChain.AuthenticateToken(ctx, bearer) if err != nil { return "", err } - return claims.Subject, nil + if !ok { + return "", fmt.Errorf("unrecognized token issuer") + } + return id, nil }, } if err := ateapiauth.ValidateServerConfig(authCfg); err != nil { + serverboot.Fatal(ctx, "Invalid auth config", err) } @@ -274,7 +315,10 @@ func loadFlagsFromEnv() { env string }{ {redisClusterAddress, "ATE_API_REDIS_ADDRESS"}, - {clientJWTIssuer, "ATE_API_K8SJWT_ISSUER"}, + {k8sJWTIssuer, "ATE_API_K8SJWT_ISSUER"}, + {k8sJWTAudience, "ATE_API_K8SJWT_AUDIENCE"}, + {humanJWTIssuer, "ATE_API_HUMAN_JWT_ISSUER"}, + {humanJWTAudience, "ATE_API_HUMAN_JWT_AUDIENCE"}, {redisUseIAMAuth, "ATE_API_REDIS_USE_IAM_AUTH"}, {redisTLSServerName, "ATE_API_REDIS_TLS_SERVER_NAME"}, {redisClientCert, "ATE_API_REDIS_CLIENT_CERT"}, @@ -295,8 +339,10 @@ func logFlagValues(ctx context.Context) { slog.String("redis-use-iam-auth", *redisUseIAMAuth), slog.String("redis-tls-server-name", *redisTLSServerName), slog.String("redis-client-cert", *redisClientCert), - slog.String("client-jwt-issuer", *clientJWTIssuer), - slog.String("client-jwt-audience", *clientJWTAudience), + slog.String("k8s-jwt-issuer", *k8sJWTIssuer), + slog.String("k8s-jwt-audience", *k8sJWTAudience), + slog.String("human-jwt-issuer", *humanJWTIssuer), + slog.String("human-jwt-audience", *humanJWTAudience), slog.String("session-id-jwt-pool", *sessionIDJWTPoolFile), slog.String("session-id-ca-pool", *sessionIDCAPoolFile), slog.String("pod-identity-ca-certs", *podIdentityCACerts), @@ -306,6 +352,7 @@ func logFlagValues(ctx context.Context) { ) } + // connectRedis builds the Redis/Valkey TLS config, plumbs IAM auth if // requested, opens the cluster client, and pings with retries. func connectRedis(ctx context.Context) (*redis.ClusterClient, error) { @@ -440,7 +487,8 @@ func buildServerCreds(ctx context.Context) (credentials.TransportCredentials, er // Kubernetes ServiceAccount issuer discovery. External issuers use system roots // and no pod ServiceAccount token. The in-cluster Kubernetes issuer trusts // caFile for TLS verification and injects the pod's ServiceAccount Bearer token -// only for URLs under issuer. Returns nil (use the k8sjwt default timeout +// only for URLs under issuer. Returns nil (use the oidcjwt default timeout + // client) if issuer is empty, or if the in-cluster issuer is configured but // caFile is empty or unreadable. func buildK8sServiceAccountIssuerDiscoveryClient(ctx context.Context, caFile, issuer string) *http.Client { diff --git a/cmd/kubectl-ate/internal/cmd/root.go b/cmd/kubectl-ate/internal/cmd/root.go index a62f6215a..11388222d 100644 --- a/cmd/kubectl-ate/internal/cmd/root.go +++ b/cmd/kubectl-ate/internal/cmd/root.go @@ -20,6 +20,7 @@ import ( "github.com/spf13/cobra" + "github.com/agent-substrate/substrate/internal/ateclient" "github.com/agent-substrate/substrate/internal/version" ) @@ -58,4 +59,6 @@ func init() { rootCmd.PersistentFlags().StringVar(&endpoint, "endpoint", "", "Manual override for the gRPC target (e.g., localhost:8080). If omitted, automatically port-forwards.") rootCmd.PersistentFlags().StringVarP(&outputFmt, "output", "o", "table", "Output format. One of: table|json|yaml") rootCmd.PersistentFlags().BoolVar(&traceEnabled, "trace", false, "Enable tracing for the request") + rootCmd.PersistentFlags().StringVarP(&ateclient.Token, "token", "t", "", "Bearer token for authentication (e.g. from gcloud auth print-identity-token). Overrides default ServiceAccount token.") } + diff --git a/hack/install-ate.sh b/hack/install-ate.sh index b8c871020..fb9789909 100755 --- a/hack/install-ate.sh +++ b/hack/install-ate.sh @@ -262,12 +262,17 @@ create_api_server_env_vars() { fi fi + local human_jwt_issuer="${HUMAN_JWT_ISSUER:-https://accounts.google.com}" + local human_jwt_audience="${HUMAN_JWT_AUDIENCE:-32555940559.apps.googleusercontent.com}" + run_kubectl create configmap -n ate-system ate-api-server-envvars \ --from-literal=ATE_API_REDIS_ADDRESS="${redis_address}" \ --from-literal=ATE_API_REDIS_USE_IAM_AUTH="${use_iam_auth}" \ --from-literal=ATE_API_REDIS_TLS_SERVER_NAME="${tls_server_name}" \ --from-literal=ATE_API_REDIS_CLIENT_CERT="${client_cert}" \ --from-literal=ATE_API_K8SJWT_ISSUER="${jwt_issuer}" \ + --from-literal=ATE_API_HUMAN_JWT_ISSUER="${human_jwt_issuer}" \ + --from-literal=ATE_API_HUMAN_JWT_AUDIENCE="${human_jwt_audience}" \ --dry-run=client -o yaml \ | run_kubectl apply -f - } diff --git a/internal/ateapiauth/server_test.go b/internal/ateapiauth/server_test.go index e9538b5d8..c4e0c27c0 100644 --- a/internal/ateapiauth/server_test.go +++ b/internal/ateapiauth/server_test.go @@ -171,7 +171,8 @@ func TestJWTServerAuthenticatorRequiresBearer(t *testing.T) { t.Fatalf("missing bearer: want Unauthenticated, got %v (err=%v)", code, err) } - // Garbage bearer -> Unauthenticated (k8sjwt.Verify will fail). + // Garbage bearer -> Unauthenticated (oidcjwt.Verify will fail). + ctx := metadata.NewIncomingContext(context.Background(), metadata.Pairs("authorization", "Bearer not-a-jwt")) _, err = auth.authenticate(ctx) if code := status.Code(err); code != codes.Unauthenticated { diff --git a/internal/ateclient/builder.go b/internal/ateclient/builder.go index 528f6af7c..89e317c2f 100644 --- a/internal/ateclient/builder.go +++ b/internal/ateclient/builder.go @@ -227,10 +227,25 @@ func serverTLSConfig(ctx context.Context, clientset kubernetes.Interface) (*tls. }, nil } +// Token allows setting an explicit Bearer token for authentication (e.g. via --token flag or ATE_TOKEN env var). +var Token string + // bearerTokenDialOption attaches a ServiceAccount token for the ate-client SA // as per-RPC credentials. func bearerTokenDialOption(ctx context.Context, clientset *kubernetes.Clientset) (grpc.DialOption, error) { + tok := Token + if tok == "" { + tok = os.Getenv("ATE_TOKEN") + } + if tok == "" { + tok = os.Getenv("ATE_API_TOKEN") + } + if tok != "" { + return grpc.WithPerRPCCredentials(bearerTokenCreds(tok)), nil + } + expirationSeconds := int64(3600) + tokenRequest := &authv1.TokenRequest{ Spec: authv1.TokenRequestSpec{ Audiences: []string{apiServerName}, diff --git a/manifests/ate-install/ate-api-server.yaml b/manifests/ate-install/ate-api-server.yaml index 7a263faac..1c548ff39 100644 --- a/manifests/ate-install/ate-api-server.yaml +++ b/manifests/ate-install/ate-api-server.yaml @@ -96,8 +96,10 @@ spec: - --redis-use-iam-auth=@env - --redis-tls-server-name=@env - --redis-client-cert=@env - - --client-jwt-issuer=@env - - --client-jwt-audience=api.ate-system.svc + - --k8s-jwt-issuer=@env + - --k8s-jwt-audience=api.ate-system.svc + - --human-jwt-issuer=@env + - --human-jwt-audience=@env - --session-id-jwt-pool=/run/session-id-jwt-pool/pool.json - --session-id-ca-pool=/run/session-id-ca-pool/pool.json - --atelet-client-cred-bundle=/run/podidentity.podcert.ate.dev/credential-bundle.pem