Support for joining Actions in un-reachable GitHub Enterprise Servers via Static JWKS (#48973)

* Add JWKS based validator for github tokens

* Extend proto

* Update auth srvr github join impl to support static jwks

* Ignore govet

* initialize with jwks validator

* Update docs

* Update terraform shizz

* Regenerate various things
This commit is contained in:
Noah Stride
2024-11-15 12:32:55 +00:00
committed by GitHub
parent 7428128897
commit c72a4f2be6
13 changed files with 2218 additions and 1846 deletions
@@ -1411,6 +1411,11 @@ message ProvisionTokenSpecV2GitHub {
// See https://docs.github.com/en/enterprise-cloud@latest/actions/deployment/security-hardening-your-deployments/about-security-hardening-with-openid-connect#customizing-the-issuer-value-for-an-enterprise
// for more information about customized issuer values.
string EnterpriseSlug = 3 [(gogoproto.jsontag) = "enterprise_slug,omitempty"];
// StaticJWKS disables fetching of the GHES signing keys via the JWKS/OIDC
// endpoints, and allows them to be directly specified. This allows joining
// from GitHub Actions in GHES instances that are not reachable by the
// Teleport Auth Service.
string StaticJWKS = 4 [(gogoproto.jsontag) = "static_jwks,omitempty"];
}
// ProvisionTokenSpecV2GitLab contains the GitLab-specific part of the
+1890 -1841
View File
File diff suppressed because it is too large Load Diff
@@ -100,6 +100,7 @@ resource, which you can apply after installing the Teleport Kubernetes operator.
|allow|[][object](#specgithuballow-items)|Allow is a list of TokenRules, nodes using this token must match one allow rule to use this token.|
|enterprise_server_host|string|EnterpriseServerHost allows joining from runners associated with a GitHub Enterprise Server instance. When unconfigured, tokens will be validated against github.com, but when configured to the host of a GHES instance, then the tokens will be validated against host. This value should be the hostname of the GHES instance, and should not include the scheme or a path. The instance must be accessible over HTTPS at this hostname and the certificate must be trusted by the Auth Service.|
|enterprise_slug|string|EnterpriseSlug allows the slug of a GitHub Enterprise organisation to be included in the expected issuer of the OIDC tokens. This is for compatibility with the `include_enterprise_slug` option in GHE. This field should be set to the slug of your enterprise if this is enabled. If this is not enabled, then this field must be left empty. This field cannot be specified if `enterprise_server_host` is specified. See https://docs.github.com/en/enterprise-cloud@latest/actions/deployment/security-hardening-your-deployments/about-security-hardening-with-openid-connect#customizing-the-issuer-value-for-an-enterprise for more information about customized issuer values.|
|static_jwks|string|StaticJWKS disables fetching of the GHES signing keys via the JWKS/OIDC endpoints, and allows them to be directly specified. This allows joining from GitHub Actions in GHES instances that are not reachable by the Teleport Auth Service.|
### spec.github.allow items
@@ -111,6 +111,7 @@ Optional:
- `allow` (Attributes List) Allow is a list of TokenRules, nodes using this token must match one allow rule to use this token. (see [below for nested schema](#nested-schema-for-specgithuballow))
- `enterprise_server_host` (String) EnterpriseServerHost allows joining from runners associated with a GitHub Enterprise Server instance. When unconfigured, tokens will be validated against github.com, but when configured to the host of a GHES instance, then the tokens will be validated against host. This value should be the hostname of the GHES instance, and should not include the scheme or a path. The instance must be accessible over HTTPS at this hostname and the certificate must be trusted by the Auth Service.
- `enterprise_slug` (String) EnterpriseSlug allows the slug of a GitHub Enterprise organisation to be included in the expected issuer of the OIDC tokens. This is for compatibility with the `include_enterprise_slug` option in GHE. This field should be set to the slug of your enterprise if this is enabled. If this is not enabled, then this field must be left empty. This field cannot be specified if `enterprise_server_host` is specified. See https://docs.github.com/en/enterprise-cloud@latest/actions/deployment/security-hardening-your-deployments/about-security-hardening-with-openid-connect#customizing-the-issuer-value-for-an-enterprise for more information about customized issuer values.
- `static_jwks` (String) StaticJWKS disables fetching of the GHES signing keys via the JWKS/OIDC endpoints, and allows them to be directly specified. This allows joining from GitHub Actions in GHES instances that are not reachable by the Teleport Auth Service.
### Nested Schema for `spec.github.allow`
@@ -145,6 +145,7 @@ Optional:
- `allow` (Attributes List) Allow is a list of TokenRules, nodes using this token must match one allow rule to use this token. (see [below for nested schema](#nested-schema-for-specgithuballow))
- `enterprise_server_host` (String) EnterpriseServerHost allows joining from runners associated with a GitHub Enterprise Server instance. When unconfigured, tokens will be validated against github.com, but when configured to the host of a GHES instance, then the tokens will be validated against host. This value should be the hostname of the GHES instance, and should not include the scheme or a path. The instance must be accessible over HTTPS at this hostname and the certificate must be trusted by the Auth Service.
- `enterprise_slug` (String) EnterpriseSlug allows the slug of a GitHub Enterprise organisation to be included in the expected issuer of the OIDC tokens. This is for compatibility with the `include_enterprise_slug` option in GHE. This field should be set to the slug of your enterprise if this is enabled. If this is not enabled, then this field must be left empty. This field cannot be specified if `enterprise_server_host` is specified. See https://docs.github.com/en/enterprise-cloud@latest/actions/deployment/security-hardening-your-deployments/about-security-hardening-with-openid-connect#customizing-the-issuer-value-for-an-enterprise for more information about customized issuer values.
- `static_jwks` (String) StaticJWKS disables fetching of the GHES signing keys via the JWKS/OIDC endpoints, and allows them to be directly specified. This allows joining from GitHub Actions in GHES instances that are not reachable by the Teleport Auth Service.
### Nested Schema for `spec.github.allow`
@@ -204,6 +204,12 @@ spec:
if `enterprise_server_host` is specified. See https://docs.github.com/en/enterprise-cloud@latest/actions/deployment/security-hardening-your-deployments/about-security-hardening-with-openid-connect#customizing-the-issuer-value-for-an-enterprise
for more information about customized issuer values.
type: string
static_jwks:
description: StaticJWKS disables fetching of the GHES signing
keys via the JWKS/OIDC endpoints, and allows them to be directly
specified. This allows joining from GitHub Actions in GHES instances
that are not reachable by the Teleport Auth Service.
type: string
type: object
gitlab:
description: GitLab allows the configuration of options specific to
@@ -204,6 +204,12 @@ spec:
if `enterprise_server_host` is specified. See https://docs.github.com/en/enterprise-cloud@latest/actions/deployment/security-hardening-your-deployments/about-security-hardening-with-openid-connect#customizing-the-issuer-value-for-an-enterprise
for more information about customized issuer values.
type: string
static_jwks:
description: StaticJWKS disables fetching of the GHES signing
keys via the JWKS/OIDC endpoints, and allows them to be directly
specified. This allows joining from GitHub Actions in GHES instances
that are not reachable by the Teleport Auth Service.
type: string
type: object
gitlab:
description: GitLab allows the configuration of options specific to
@@ -274,6 +274,11 @@ func GenSchemaProvisionTokenV2(ctx context.Context) (github_com_hashicorp_terraf
Optional: true,
Type: github_com_hashicorp_terraform_plugin_framework_types.StringType,
},
"static_jwks": {
Description: "StaticJWKS disables fetching of the GHES signing keys via the JWKS/OIDC endpoints, and allows them to be directly specified. This allows joining from GitHub Actions in GHES instances that are not reachable by the Teleport Auth Service.",
Optional: true,
Type: github_com_hashicorp_terraform_plugin_framework_types.StringType,
},
}),
Description: "GitHub allows the configuration of options specific to the \"github\" join method.",
Optional: true,
@@ -1166,6 +1171,23 @@ func CopyProvisionTokenV2FromTerraform(_ context.Context, tf github_com_hashicor
}
}
}
{
a, ok := tf.Attrs["static_jwks"]
if !ok {
diags.Append(attrReadMissingDiag{"ProvisionTokenV2.Spec.GitHub.StaticJWKS"})
} else {
v, ok := a.(github_com_hashicorp_terraform_plugin_framework_types.String)
if !ok {
diags.Append(attrReadConversionFailureDiag{"ProvisionTokenV2.Spec.GitHub.StaticJWKS", "github.com/hashicorp/terraform-plugin-framework/types.String"})
} else {
var t string
if !v.Null && !v.Unknown {
t = string(v.Value)
}
obj.StaticJWKS = t
}
}
}
}
}
}
@@ -3292,6 +3314,28 @@ func CopyProvisionTokenV2ToTerraform(ctx context.Context, obj *github_com_gravit
tf.Attrs["enterprise_slug"] = v
}
}
{
t, ok := tf.AttrTypes["static_jwks"]
if !ok {
diags.Append(attrWriteMissingDiag{"ProvisionTokenV2.Spec.GitHub.StaticJWKS"})
} else {
v, ok := tf.Attrs["static_jwks"].(github_com_hashicorp_terraform_plugin_framework_types.String)
if !ok {
i, err := t.ValueFromTerraform(ctx, github_com_hashicorp_terraform_plugin_go_tftypes.NewValue(t.TerraformType(ctx), nil))
if err != nil {
diags.Append(attrWriteGeneralError{"ProvisionTokenV2.Spec.GitHub.StaticJWKS", err})
}
v, ok = i.(github_com_hashicorp_terraform_plugin_framework_types.String)
if !ok {
diags.Append(attrWriteConversionFailureDiag{"ProvisionTokenV2.Spec.GitHub.StaticJWKS", "github.com/hashicorp/terraform-plugin-framework/types.String"})
}
v.Null = string(obj.StaticJWKS) == ""
}
v.Value = string(obj.StaticJWKS)
v.Unknown = false
tf.Attrs["static_jwks"] = v
}
}
}
v.Unknown = false
tf.Attrs["github"] = v
+7
View File
@@ -568,6 +568,9 @@ func NewServer(cfg *InitConfig, opts ...ServerOption) (*Server, error) {
},
)
}
if as.ghaIDTokenJWKSValidator == nil {
as.ghaIDTokenJWKSValidator = githubactions.ValidateTokenWithJWKS
}
if as.spaceliftIDTokenValidator == nil {
as.spaceliftIDTokenValidator = spacelift.NewIDTokenValidator(
spacelift.IDTokenValidatorConfig{
@@ -985,6 +988,10 @@ type Server struct {
// ghaIDTokenValidator allows ID tokens from GitHub Actions to be validated
// by the auth server. It can be overridden for the purpose of tests.
ghaIDTokenValidator ghaIDTokenValidator
// ghaIDTokenJWKSValidator allows ID tokens from GitHub Actions to be
// validated by the auth server using a known JWKS. It can be overridden for
//the purpose of tests.
ghaIDTokenJWKSValidator ghaIDTokenJWKSValidator
// spaceliftIDTokenValidator allows ID tokens from Spacelift to be validated
// by the auth server. It can be overridden for the purpose of tests.
+22 -5
View File
@@ -21,6 +21,7 @@ package auth
import (
"context"
"fmt"
"time"
"github.com/gravitational/trace"
"github.com/sirupsen/logrus"
@@ -36,6 +37,10 @@ type ghaIDTokenValidator interface {
) (*githubactions.IDTokenClaims, error)
}
type ghaIDTokenJWKSValidator func(
now time.Time, jwksData []byte, token string,
) (*githubactions.IDTokenClaims, error)
func (a *Server) checkGitHubJoinRequest(ctx context.Context, req *types.RegisterUsingTokenRequest) (*githubactions.IDTokenClaims, error) {
if req.IDToken == "" {
return nil, trace.BadParameter("IDToken not provided for Github join request")
@@ -63,11 +68,23 @@ func (a *Server) checkGitHubJoinRequest(ctx context.Context, req *types.Register
}
}
claims, err := a.ghaIDTokenValidator.Validate(
ctx, enterpriseOverride, enterpriseSlug, req.IDToken,
)
if err != nil {
return nil, trace.Wrap(err)
var claims *githubactions.IDTokenClaims
if token.Spec.GitHub.StaticJWKS != "" {
claims, err = a.ghaIDTokenJWKSValidator(
a.clock.Now().UTC(),
[]byte(token.Spec.GitHub.StaticJWKS),
req.IDToken,
)
if err != nil {
return nil, trace.Wrap(err, "validating with jwks")
}
} else {
claims, err = a.ghaIDTokenValidator.Validate(
ctx, enterpriseOverride, enterpriseSlug, req.IDToken,
)
if err != nil {
return nil, trace.Wrap(err, "validating with oidc")
}
}
log.WithFields(logrus.Fields{
+49
View File
@@ -37,6 +37,7 @@ type mockIDTokenValidator struct {
tokens map[string]githubactions.IDTokenClaims
lastCalledGHESHost string
lastCalledEnterpriseSlug string
lastCalledJWKS string
}
var errMockInvalidToken = errors.New("invalid token")
@@ -57,6 +58,18 @@ func (m *mockIDTokenValidator) Validate(
func (m *mockIDTokenValidator) reset() {
m.lastCalledGHESHost = ""
m.lastCalledEnterpriseSlug = ""
m.lastCalledJWKS = ""
}
func (m *mockIDTokenValidator) ValidateJWKS(
_ time.Time, jwks []byte, token string,
) (*githubactions.IDTokenClaims, error) {
m.lastCalledJWKS = string(jwks)
claims, ok := m.tokens[token]
if !ok {
return nil, errMockInvalidToken
}
return &claims, nil
}
func TestAuth_RegisterUsingToken_GHA(t *testing.T) {
@@ -77,6 +90,7 @@ func TestAuth_RegisterUsingToken_GHA(t *testing.T) {
}
var withTokenValidator ServerOption = func(server *Server) error {
server.ghaIDTokenValidator = idTokenValidator
server.ghaIDTokenJWKSValidator = idTokenValidator.ValidateJWKS
return nil
}
ctx := context.Background()
@@ -141,6 +155,36 @@ func TestAuth_RegisterUsingToken_GHA(t *testing.T) {
request: newRequest(validIDToken),
assertError: require.NoError,
},
{
name: "success with jwks",
tokenSpec: types.ProvisionTokenSpecV2{
JoinMethod: types.JoinMethodGitHub,
Roles: []types.SystemRole{types.RoleNode},
GitHub: &types.ProvisionTokenSpecV2GitHub{
Allow: []*types.ProvisionTokenSpecV2GitHub_Rule{
allowRule(nil),
},
StaticJWKS: "my-jwks",
},
},
request: newRequest(validIDToken),
assertError: require.NoError,
},
{
name: "failure with jwks",
tokenSpec: types.ProvisionTokenSpecV2{
JoinMethod: types.JoinMethodGitHub,
Roles: []types.SystemRole{types.RoleNode},
GitHub: &types.ProvisionTokenSpecV2GitHub{
Allow: []*types.ProvisionTokenSpecV2GitHub_Rule{
allowRule(nil),
},
StaticJWKS: "my-jwks",
},
},
request: newRequest("invalid"),
assertError: require.Error,
},
{
name: "ghes override",
tokenSpec: types.ProvisionTokenSpecV2{
@@ -385,6 +429,11 @@ func TestAuth_RegisterUsingToken_GHA(t *testing.T) {
tt.tokenSpec.GitHub.EnterpriseSlug,
idTokenValidator.lastCalledEnterpriseSlug,
)
require.Equal(
t,
tt.tokenSpec.GitHub.StaticJWKS,
idTokenValidator.lastCalledJWKS,
)
})
}
}
+45
View File
@@ -20,10 +20,13 @@ package githubactions
import (
"context"
"encoding/json"
"fmt"
"time"
"github.com/coreos/go-oidc"
"github.com/go-jose/go-jose/v3"
josejwt "github.com/go-jose/go-jose/v3/jwt"
"github.com/gravitational/trace"
"github.com/jonboulle/clockwork"
@@ -113,3 +116,45 @@ func (id *IDTokenValidator) Validate(
}
return &claims, nil
}
// ValidateTokenWithJWKS validates a GitHub Actions JWT using a configured
// JWKS rather than fetching from well-known. This supports cases where GHES
// is not accessible to the Teleport Auth Server.
func ValidateTokenWithJWKS(
now time.Time,
jwksData []byte,
token string,
) (*IDTokenClaims, error) {
parsed, err := josejwt.ParseSigned(token)
if err != nil {
return nil, trace.Wrap(err, "parsing jwt")
}
jwks := jose.JSONWebKeySet{}
if err := json.Unmarshal(jwksData, &jwks); err != nil {
return nil, trace.Wrap(err, "parsing provided jwks")
}
stdClaims := josejwt.Claims{}
if err := parsed.Claims(jwks, &stdClaims); err != nil {
return nil, trace.Wrap(err, "validating jwt signature")
}
leeway := time.Second * 10
err = stdClaims.ValidateWithLeeway(josejwt.Expected{
Audience: []string{
"teleport.cluster.local",
},
Time: now,
}, leeway)
if err != nil {
return nil, trace.Wrap(err, "validating standard claims")
}
claims := IDTokenClaims{}
if err := parsed.Claims(jwks, &claims); err != nil {
return nil, trace.Wrap(err, "validating custom claims")
}
return &claims, nil
}
+141
View File
@@ -353,3 +353,144 @@ func TestIDTokenValidator_Validate(t *testing.T) {
})
}
}
func testSigner(t *testing.T) ([]byte, jose.Signer) {
key, err := cryptosuites.GenerateKeyWithAlgorithm(cryptosuites.ECDSAP256)
require.NoError(t, err)
signer, err := jose.NewSigner(
jose.SigningKey{Algorithm: jose.ES256, Key: key},
(&jose.SignerOptions{}).
WithType("JWT").
WithHeader("kid", "foo"),
)
require.NoError(t, err)
jwks := jose.JSONWebKeySet{Keys: []jose.JSONWebKey{
{
Key: key.Public(),
Use: "sig",
Algorithm: string(jose.ES256),
KeyID: "foo",
},
}}
jwksData, err := json.Marshal(jwks)
require.NoError(t, err)
return jwksData, signer
}
//nolint:govet // there's some weird json struct tag overlap here
type claims struct {
jwt.Claims
IDTokenClaims
Subject string `json:"sub"`
}
func TestValidateTokenWithJWKS(t *testing.T) {
jwks, signer := testSigner(t)
_, wrongSigner := testSigner(t)
now := time.Now()
clusterName := "teleport.cluster.local"
tests := []struct {
name string
signer jose.Signer
claims claims
wantResult *IDTokenClaims
wantErr string
}{
{
name: "valid token",
signer: signer,
claims: claims{
IDTokenClaims: IDTokenClaims{
Repository: "123",
},
Subject: "foo",
Claims: jwt.Claims{
Audience: jwt.Audience{clusterName},
IssuedAt: jwt.NewNumericDate(now.Add(-1 * time.Minute)),
NotBefore: jwt.NewNumericDate(now.Add(-1 * time.Minute)),
Expiry: jwt.NewNumericDate(now.Add(10 * time.Minute)),
},
},
wantResult: &IDTokenClaims{
Sub: "foo",
Repository: "123",
},
},
{
name: "signed by wrong signer",
signer: wrongSigner,
claims: claims{
IDTokenClaims: IDTokenClaims{
Repository: "123",
},
Subject: "foo",
Claims: jwt.Claims{
Audience: jwt.Audience{clusterName},
IssuedAt: jwt.NewNumericDate(now.Add(-1 * time.Minute)),
NotBefore: jwt.NewNumericDate(now.Add(-1 * time.Minute)),
Expiry: jwt.NewNumericDate(now.Add(10 * time.Minute)),
},
},
wantResult: &IDTokenClaims{
Sub: "foo",
Repository: "123",
},
wantErr: "validating jwt signature",
},
{
name: "expired",
signer: signer,
claims: claims{
IDTokenClaims: IDTokenClaims{
Repository: "123",
},
Subject: "foo",
Claims: jwt.Claims{
Audience: jwt.Audience{clusterName},
IssuedAt: jwt.NewNumericDate(now.Add(-2 * time.Minute)),
NotBefore: jwt.NewNumericDate(now.Add(-2 * time.Minute)),
Expiry: jwt.NewNumericDate(now.Add(-1 * time.Minute)),
},
},
wantErr: "token is expired",
},
{
name: "not yet valid",
signer: signer,
claims: claims{
IDTokenClaims: IDTokenClaims{
Repository: "123",
},
Subject: "foo",
Claims: jwt.Claims{
Audience: jwt.Audience{clusterName},
IssuedAt: jwt.NewNumericDate(now.Add(2 * time.Minute)),
NotBefore: jwt.NewNumericDate(now.Add(2 * time.Minute)),
Expiry: jwt.NewNumericDate(now.Add(4 * time.Minute)),
},
},
wantErr: "token not valid yet",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
token, err := jwt.Signed(tt.signer).
Claims(tt.claims).
CompactSerialize()
require.NoError(t, err)
result, err := ValidateTokenWithJWKS(now, jwks, token)
if tt.wantErr != "" {
require.ErrorContains(t, err, tt.wantErr)
return
}
require.NoError(t, err)
require.Equal(t, tt.wantResult, result)
})
}
}