Support configuring StaticJWKS for the GitLab join method (#53386)

* Update protobuf

* Implement and test ValidateTokenWithJWKS

* Update join logic to use new method for static jwks

* Update Terraform Provider

* Update operator docs/files

* Update goldenfile
This commit is contained in:
Noah Stride
2025-03-25 18:03:09 +00:00
committed by GitHub
parent a5f425a221
commit 7cbb46d2ed
12 changed files with 2206 additions and 1858 deletions
@@ -1594,6 +1594,11 @@ message ProvisionTokenSpecV2GitLab {
// `gitlab.com` - but can be set to the domain of your self-hosted GitLab
// e.g `gitlab.example.com`.
string Domain = 2 [(gogoproto.jsontag) = "domain,omitempty"];
// StaticJWKS disables fetching of the GitLab signing keys via the JWKS/OIDC
// endpoints, and allows them to be directly specified. This allows joining
// from GitLab CI instances that are not reachable by the Teleport Auth
// Service.
string StaticJWKS = 3 [(gogoproto.jsontag) = "static_jwks,omitempty"];
}
// ProvisionTokenSpecV2CircleCI contains the CircleCI-specific part of the
+1897 -1848
View File
File diff suppressed because it is too large Load Diff
@@ -140,6 +140,7 @@ resource, which you can apply after installing the Teleport Kubernetes operator.
|---|---|---|
|allow|[][object](#specgitlaballow-items)|Allow is a list of TokenRules, nodes using this token must match one allow rule to use this token.|
|domain|string|Domain is the domain of your GitLab instance. This will default to `gitlab.com` - but can be set to the domain of your self-hosted GitLab e.g `gitlab.example.com`.|
|static_jwks|string|StaticJWKS disables fetching of the GitLab signing keys via the JWKS/OIDC endpoints, and allows them to be directly specified. This allows joining from GitLab CI instances that are not reachable by the Teleport Auth Service.|
### spec.gitlab.allow items
@@ -156,6 +156,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-specgitlaballow))
- `domain` (String) Domain is the domain of your GitLab instance. This will default to `gitlab.com` - but can be set to the domain of your self-hosted GitLab e.g `gitlab.example.com`.
- `static_jwks` (String) StaticJWKS disables fetching of the GitLab signing keys via the JWKS/OIDC endpoints, and allows them to be directly specified. This allows joining from GitLab CI instances that are not reachable by the Teleport Auth Service.
### Nested Schema for `spec.gitlab.allow`
@@ -190,6 +190,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-specgitlaballow))
- `domain` (String) Domain is the domain of your GitLab instance. This will default to `gitlab.com` - but can be set to the domain of your self-hosted GitLab e.g `gitlab.example.com`.
- `static_jwks` (String) StaticJWKS disables fetching of the GitLab signing keys via the JWKS/OIDC endpoints, and allows them to be directly specified. This allows joining from GitLab CI instances that are not reachable by the Teleport Auth Service.
### Nested Schema for `spec.gitlab.allow`
@@ -295,6 +295,12 @@ spec:
will default to `gitlab.com` - but can be set to the domain
of your self-hosted GitLab e.g `gitlab.example.com`.
type: string
static_jwks:
description: StaticJWKS disables fetching of the GitLab signing
keys via the JWKS/OIDC endpoints, and allows them to be directly
specified. This allows joining from GitLab CI instances that
are not reachable by the Teleport Auth Service.
type: string
type: object
join_method:
description: 'JoinMethod is the joining method required in order to
@@ -295,6 +295,12 @@ spec:
will default to `gitlab.com` - but can be set to the domain
of your self-hosted GitLab e.g `gitlab.example.com`.
type: string
static_jwks:
description: StaticJWKS disables fetching of the GitLab signing
keys via the JWKS/OIDC endpoints, and allows them to be directly
specified. This allows joining from GitLab CI instances that
are not reachable by the Teleport Auth Service.
type: string
type: object
join_method:
description: 'JoinMethod is the joining method required in order to
@@ -416,6 +416,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 GitLab signing keys via the JWKS/OIDC endpoints, and allows them to be directly specified. This allows joining from GitLab CI instances that are not reachable by the Teleport Auth Service.",
Optional: true,
Type: github_com_hashicorp_terraform_plugin_framework_types.StringType,
},
}),
Description: "GitLab allows the configuration of options specific to the \"gitlab\" join method.",
Optional: true,
@@ -1883,6 +1888,23 @@ func CopyProvisionTokenV2FromTerraform(_ context.Context, tf github_com_hashicor
}
}
}
{
a, ok := tf.Attrs["static_jwks"]
if !ok {
diags.Append(attrReadMissingDiag{"ProvisionTokenV2.Spec.GitLab.StaticJWKS"})
} else {
v, ok := a.(github_com_hashicorp_terraform_plugin_framework_types.String)
if !ok {
diags.Append(attrReadConversionFailureDiag{"ProvisionTokenV2.Spec.GitLab.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
}
}
}
}
}
}
@@ -4626,6 +4648,28 @@ func CopyProvisionTokenV2ToTerraform(ctx context.Context, obj *github_com_gravit
tf.Attrs["domain"] = v
}
}
{
t, ok := tf.AttrTypes["static_jwks"]
if !ok {
diags.Append(attrWriteMissingDiag{"ProvisionTokenV2.Spec.GitLab.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.GitLab.StaticJWKS", err})
}
v, ok = i.(github_com_hashicorp_terraform_plugin_framework_types.String)
if !ok {
diags.Append(attrWriteConversionFailureDiag{"ProvisionTokenV2.Spec.GitLab.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["gitlab"] = v
+18 -5
View File
@@ -33,6 +33,9 @@ type gitlabIDTokenValidator interface {
Validate(
ctx context.Context, domain string, token string,
) (*gitlab.IDTokenClaims, error)
ValidateTokenWithJWKS(
ctx context.Context, jwks []byte, token string,
) (*gitlab.IDTokenClaims, error)
}
func (a *Server) checkGitLabJoinRequest(ctx context.Context, req *types.RegisterUsingTokenRequest) (*gitlab.IDTokenClaims, error) {
@@ -49,11 +52,21 @@ func (a *Server) checkGitLabJoinRequest(ctx context.Context, req *types.Register
return nil, trace.BadParameter("gitlab join method only supports ProvisionTokenV2, '%T' was provided", pt)
}
claims, err := a.gitlabIDTokenValidator.Validate(
ctx, token.Spec.GitLab.Domain, req.IDToken,
)
if err != nil {
return nil, trace.Wrap(err)
var claims *gitlab.IDTokenClaims
if token.Spec.GitLab.StaticJWKS != "" {
claims, err = a.gitlabIDTokenValidator.ValidateTokenWithJWKS(
ctx, []byte(token.Spec.GitLab.StaticJWKS), req.IDToken,
)
if err != nil {
return nil, trace.Wrap(err, "validating with static jwks")
}
} else {
claims, err = a.gitlabIDTokenValidator.Validate(
ctx, token.Spec.GitLab.Domain, req.IDToken,
)
if err != nil {
return nil, trace.Wrap(err, "validating with oidc")
}
}
a.logger.InfoContext(ctx, "GitLab CI run trying to join cluster",
+53
View File
@@ -34,6 +34,7 @@ import (
type mockGitLabTokenValidator struct {
tokens map[string]gitlab.IDTokenClaims
lastCalledDomain string
lastCalledJWKS []byte
}
func (m *mockGitLabTokenValidator) Validate(
@@ -49,6 +50,19 @@ func (m *mockGitLabTokenValidator) Validate(
return &claims, nil
}
func (m *mockGitLabTokenValidator) ValidateTokenWithJWKS(
_ context.Context, jwks []byte, token string,
) (*gitlab.IDTokenClaims, error) {
m.lastCalledJWKS = jwks
claims, ok := m.tokens[token]
if !ok {
return nil, errMockInvalidToken
}
return &claims, nil
}
func TestAuth_RegisterUsingToken_GitLab(t *testing.T) {
validIDToken := "test.fake.jwt"
idTokenValidator := &mockGitLabTokenValidator{
@@ -490,6 +504,36 @@ func TestAuth_RegisterUsingToken_GitLab(t *testing.T) {
request: newRequest(validIDToken),
assertError: allowRulesNotMatched,
},
{
name: "success with JWKS",
tokenSpec: types.ProvisionTokenSpecV2{
JoinMethod: types.JoinMethodGitLab,
Roles: []types.SystemRole{types.RoleNode},
GitLab: &types.ProvisionTokenSpecV2GitLab{
Allow: []*types.ProvisionTokenSpecV2GitLab_Rule{
allowRule(nil),
},
StaticJWKS: "xyzzy",
},
},
request: newRequest(validIDToken),
assertError: require.NoError,
},
{
name: "failure with JWKS",
tokenSpec: types.ProvisionTokenSpecV2{
JoinMethod: types.JoinMethodGitLab,
Roles: []types.SystemRole{types.RoleNode},
GitLab: &types.ProvisionTokenSpecV2GitLab{
Allow: []*types.ProvisionTokenSpecV2GitLab_Rule{
allowRule(nil),
},
StaticJWKS: "xyzzy",
},
},
request: newRequest("invalidjwt"),
assertError: require.Error,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
@@ -510,6 +554,15 @@ func TestAuth_RegisterUsingToken_GitLab(t *testing.T) {
idTokenValidator.lastCalledDomain,
)
}
if tt.tokenSpec.GitLab.StaticJWKS != "" {
require.Equal(
t,
[]byte(tt.tokenSpec.GitLab.StaticJWKS),
idTokenValidator.lastCalledJWKS,
)
} else {
require.Nil(t, idTokenValidator.lastCalledJWKS)
}
})
}
}
+49
View File
@@ -20,10 +20,13 @@ package gitlab
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"
@@ -118,3 +121,49 @@ func (id *IDTokenValidator) Validate(
}
return &claims, nil
}
// ValidateTokenWithJWKS validates a token using the provided JWKS data.
// Used in cases where GitLab is not reachable from the Teleport cluster.
func (id *IDTokenValidator) ValidateTokenWithJWKS(
ctx context.Context,
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")
}
clusterNameResource, err := id.ClusterNameGetter.GetClusterName(ctx)
if err != nil {
return nil, trace.Wrap(err, "getting cluster name")
}
leeway := time.Second * 10
err = stdClaims.ValidateWithLeeway(josejwt.Expected{
Audience: []string{
clusterNameResource.GetClusterName(),
},
Time: id.Clock.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
}
+125 -5
View File
@@ -42,15 +42,18 @@ type fakeIDP struct {
signer jose.Signer
publicKey crypto.PublicKey
server *httptest.Server
kid string
}
func newFakeIDP(t *testing.T) *fakeIDP {
privateKey, err := cryptosuites.GenerateKeyWithAlgorithm(cryptosuites.RSA2048)
require.NoError(t, err)
kid := "xyzzy"
signer, err := jose.NewSigner(
jose.SigningKey{Algorithm: jose.RS256, Key: privateKey},
(&jose.SignerOptions{}).WithType("JWT"),
(&jose.SignerOptions{}).WithType("JWT").WithHeader("kid", kid),
)
require.NoError(t, err)
@@ -58,6 +61,7 @@ func newFakeIDP(t *testing.T) *fakeIDP {
signer: signer,
publicKey: privateKey.Public(),
t: t,
kid: kid,
}
providerMux := http.NewServeMux()
@@ -108,17 +112,26 @@ func (f *fakeIDP) handleOpenIDConfig(w http.ResponseWriter, r *http.Request) {
func (f *fakeIDP) handleJWKSEndpoint(w http.ResponseWriter, r *http.Request) {
// mimic https://gitlab.com/oauth/discovery/keys
// but with our own keys
responseBytes, err := f.jwks()
require.NoError(f.t, err)
_, err = w.Write(responseBytes)
require.NoError(f.t, err)
}
func (f *fakeIDP) jwks() ([]byte, error) {
jwks := jose.JSONWebKeySet{
Keys: []jose.JSONWebKey{
{
Key: f.publicKey,
Key: f.publicKey,
KeyID: f.kid,
},
},
}
responseBytes, err := json.Marshal(jwks)
require.NoError(f.t, err)
_, err = w.Write(responseBytes)
require.NoError(f.t, err)
if err != nil {
return nil, err
}
return responseBytes, nil
}
func (f *fakeIDP) issueToken(
@@ -260,3 +273,110 @@ func TestIDTokenValidator_Validate(t *testing.T) {
})
}
}
func TestIDTokenValidator_ValidateWithJWKS(t *testing.T) {
t.Parallel()
idp := newFakeIDP(t)
wrongIdp := newFakeIDP(t)
teleportClusterName := "teleport.example.com"
tests := []struct {
name string
assertError require.ErrorAssertionFunc
jwksSource *fakeIDP
want *IDTokenClaims
token string
}{
{
name: "success",
assertError: require.NoError,
token: idp.issueToken(
t,
idp.issuer(),
teleportClusterName,
"unpetitchien",
"project_path:mygroup/my-project:ref_type:branch:ref:main",
time.Now().Add(-5*time.Minute),
time.Now().Add(5*time.Minute),
),
want: &IDTokenClaims{
UserLogin: "unpetitchien",
Sub: "project_path:mygroup/my-project:ref_type:branch:ref:main",
},
},
{
name: "expired",
assertError: require.Error,
token: idp.issueToken(
t,
idp.issuer(),
teleportClusterName,
"octocat",
"project_path:mygroup/my-project:ref_type:branch:ref:main",
time.Now().Add(-15*time.Minute),
time.Now().Add(-5*time.Minute),
),
},
{
name: "future",
assertError: require.Error,
token: idp.issueToken(
t,
idp.issuer(),
teleportClusterName,
"octocat",
"project_path:mygroup/my-project:ref_type:branch:ref:main",
time.Now().Add(10*time.Minute),
time.Now().Add(20*time.Minute),
),
},
{
name: "invalid audience",
assertError: require.Error,
token: idp.issueToken(
t,
idp.issuer(),
"wrong-teleport.example.com",
"octocat",
"project_path:mygroup/my-project:ref_type:branch:ref:main",
time.Now().Add(-5*time.Minute),
time.Now().Add(5*time.Minute),
),
},
{
name: "wrong issuer",
assertError: require.Error,
token: wrongIdp.issueToken(
t,
"https://the.wrong.issuer",
teleportClusterName,
"octocat",
"project_path:mygroup/my-project:ref_type:branch:ref:main",
time.Now().Add(-5*time.Minute),
time.Now().Add(5*time.Minute),
),
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ctx := context.Background()
v, err := NewIDTokenValidator(IDTokenValidatorConfig{
Clock: clockwork.NewRealClock(),
insecure: true,
ClusterNameGetter: mockClusterNameGetter(teleportClusterName),
})
require.NoError(t, err)
jwks, err := idp.jwks()
require.NoError(t, err)
claims, err := v.ValidateTokenWithJWKS(
ctx,
jwks,
tt.token,
)
tt.assertError(t, err)
require.Equal(t, tt.want, claims)
})
}
}