mirror of
https://github.com/gravitational/teleport.git
synced 2026-09-24 16:17:11 +08:00
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:
@@ -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
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`
|
||||
|
||||
|
||||
+6
@@ -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
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user