diff --git a/lib/auth/machineid/machineidv1/workload_identity_service.go b/lib/auth/machineid/machineidv1/workload_identity_service.go index 3c71bb14ffe8d..9efe34f44f4bd 100644 --- a/lib/auth/machineid/machineidv1/workload_identity_service.go +++ b/lib/auth/machineid/machineidv1/workload_identity_service.go @@ -44,6 +44,7 @@ import ( "github.com/gravitational/teleport/lib/tlsca" usagereporter "github.com/gravitational/teleport/lib/usagereporter/teleport" "github.com/gravitational/teleport/lib/utils" + "github.com/gravitational/teleport/lib/utils/oidc" ) const ( @@ -72,6 +73,7 @@ type WorkloadIdentityServiceConfig struct { type WorkloadIdentityCacher interface { GetCertAuthority(ctx context.Context, id types.CertAuthID, loadKeys bool) (types.CertAuthority, error) GetClusterName(opts ...services.MarshalOption) (types.ClusterName, error) + GetProxies() ([]types.Server, error) } // KeyStorer is an interface that provides methods to retrieve keys and @@ -375,6 +377,7 @@ func (wis *WorkloadIdentityService) signJWTSVID( ctx context.Context, authCtx *authz.Context, clusterName string, + issuer string, key *jwt.Key, req *pb.JWTSVIDRequest, ) (res *pb.JWTSVIDResponse, err error) { @@ -457,6 +460,7 @@ func (wis *WorkloadIdentityService) signJWTSVID( SPIFFEID: spiffeID, TTL: ttl, JTI: jti, + Issuer: issuer, }) if err != nil { return nil, trace.Wrap(err, "signing jwt") @@ -507,10 +511,17 @@ func (wis *WorkloadIdentityService) SignJWTSVIDs( return nil, trace.Wrap(err, "getting JWT key") } + // Determine the public address of the proxy for inclusion in the JWT as + // the issuer for purposes of OIDC compatibility. + issuer, err := oidc.IssuerForCluster(ctx, wis.cache, "/workload-identity") + if err != nil { + return nil, trace.Wrap(err, "determining issuer") + } + res := &pb.SignJWTSVIDsResponse{} for i, svidReq := range req.Svids { svidRes, err := wis.signJWTSVID( - ctx, authCtx, clusterName.GetClusterName(), jwtKey, svidReq, + ctx, authCtx, clusterName.GetClusterName(), issuer, jwtKey, svidReq, ) if err != nil { return nil, trace.Wrap(err, "signing svid %d", i) diff --git a/lib/auth/machineid/machineidv1/workload_identity_service_test.go b/lib/auth/machineid/machineidv1/workload_identity_service_test.go index a225b4a03f395..38bff691642f8 100644 --- a/lib/auth/machineid/machineidv1/workload_identity_service_test.go +++ b/lib/auth/machineid/machineidv1/workload_identity_service_test.go @@ -289,6 +289,16 @@ func TestWorkloadIdentityService_SignJWTSVIDs(t *testing.T) { kid, err := libjwt.KeyID(jwtSigner.Public()) require.NoError(t, err) + // Upsert a fake proxy to ensure we have a public address to use for the + // issuer. + proxy, err := types.NewServer("proxy", types.KindProxy, types.ServerSpecV2{ + PublicAddrs: []string{"teleport.example.com"}, + }) + require.NoError(t, err) + err = srv.Auth().UpsertProxy(ctx, proxy) + require.NoError(t, err) + wantIssuer := "https://teleport.example.com/workload-identity" + tests := []struct { name string user string @@ -336,6 +346,7 @@ func TestWorkloadIdentityService_SignJWTSVIDs(t *testing.T) { require.Equal(t, wantSPIFFEID, claims.Subject) require.Equal(t, svid.Jti, claims.ID) require.Equal(t, "example.com", claims.Audience[0]) + require.Equal(t, wantIssuer, claims.Issuer) require.WithinDuration(t, time.Now().Add(30*time.Minute), claims.Expiry.Time(), 5*time.Second) require.WithinDuration(t, time.Now(), claims.IssuedAt.Time(), 5*time.Second) }, diff --git a/lib/integrations/awsoidc/idp_thumbprint.go b/lib/integrations/awsoidc/idp_thumbprint.go index 3421207cd6914..80f0886d9a9c5 100644 --- a/lib/integrations/awsoidc/idp_thumbprint.go +++ b/lib/integrations/awsoidc/idp_thumbprint.go @@ -37,7 +37,7 @@ import ( // Returns the thumbprint of the top intermediate CA that signed the TLS cert used to serve HTTPS requests. // In case of a self signed certificate, then it returns the thumbprint of the TLS cert itself. func ThumbprintIdP(ctx context.Context, publicAddress string) (string, error) { - issuer, err := oidc.IssuerFromPublicAddress(publicAddress) + issuer, err := oidc.IssuerFromPublicAddress(publicAddress, "") if err != nil { return "", trace.Wrap(err) } diff --git a/lib/integrations/awsoidc/token_generator.go b/lib/integrations/awsoidc/token_generator.go index 9cbcbde2d6de3..afcd807325f9b 100644 --- a/lib/integrations/awsoidc/token_generator.go +++ b/lib/integrations/awsoidc/token_generator.go @@ -89,7 +89,7 @@ func (g *GenerateAWSOIDCTokenRequest) CheckAndSetDefaults() error { func issuerForIntegration(ctx context.Context, integration types.Integration, cacheClt Cache) (string, error) { issuerS3URI := integration.GetAWSOIDCIntegrationSpec().IssuerS3URI if issuerS3URI == "" { - issuer, err := oidc.IssuerForCluster(ctx, cacheClt) + issuer, err := oidc.IssuerForCluster(ctx, cacheClt, "") return issuer, trace.Wrap(err) } diff --git a/lib/integrations/azureoidc/token_generator.go b/lib/integrations/azureoidc/token_generator.go index c938ceb6debf4..afa306d880fd4 100644 --- a/lib/integrations/azureoidc/token_generator.go +++ b/lib/integrations/azureoidc/token_generator.go @@ -70,7 +70,7 @@ func GenerateEntraOIDCToken(ctx context.Context, cache Cache, manager KeyStoreMa return "", trace.Wrap(err) } - issuer, err := oidc.IssuerForCluster(ctx, cache) + issuer, err := oidc.IssuerForCluster(ctx, cache, "") if err != nil { return "", trace.Wrap(err) } diff --git a/lib/jwt/jwt.go b/lib/jwt/jwt.go index b89d3bbbba893..bc21e58953b0a 100644 --- a/lib/jwt/jwt.go +++ b/lib/jwt/jwt.go @@ -277,6 +277,9 @@ type SignParamsJWTSVID struct { Audiences []string // TTL is the time to live for the token. TTL time.Duration + // Issuer is the value that should be included in the `iss` claim of the + // created token. + Issuer string } // SignJWTSVID signs a JWT SVID token. @@ -302,6 +305,11 @@ func (k *Key) SignJWTSVID(p SignParamsJWTSVID) (string, error) { // > noted that JWT-SVID validators are not required to track jti // > uniqueness. ID: p.JTI, + // The SPIFFE specification makes no comment on the inclusion of `iss`, + // however, we provide this value so that the issued token can be a + // valid OIDC ID token and used with non-SPIFFE aware systems that do + // understand OIDC. + Issuer: p.Issuer, } // > 2.2. Key ID: diff --git a/lib/utils/oidc/issuer.go b/lib/utils/oidc/issuer.go index 284e215f6d0a4..a777dbebaac79 100644 --- a/lib/utils/oidc/issuer.go +++ b/lib/utils/oidc/issuer.go @@ -35,7 +35,9 @@ type ProxiesGetter interface { } // IssuerForCluster returns the issuer URL using the Cluster state. -func IssuerForCluster(ctx context.Context, clt ProxiesGetter) (string, error) { +// Path is an optional element to append to the issuer to distinguish a +// separate CA within the same cluster. +func IssuerForCluster(ctx context.Context, clt ProxiesGetter, path string) (string, error) { proxies, err := clt.GetProxies() if err != nil { return "", trace.Wrap(err) @@ -44,18 +46,22 @@ func IssuerForCluster(ctx context.Context, clt ProxiesGetter) (string, error) { for _, p := range proxies { proxyPublicAddress := p.GetPublicAddr() if proxyPublicAddress != "" { - return IssuerFromPublicAddress(proxyPublicAddress) + return IssuerFromPublicAddress(proxyPublicAddress, path) } } return "", trace.BadParameter("failed to get Proxy Public Address") } -// IssuerFromPublicAddress is the address for the AWS OIDC Provider. +// IssuerFromPublicAddress is the address for an OIDC Provider. +// // It must match exactly what was introduced in AWS IAM console when adding the Identity Provider. // PublicProxyAddr from `teleport.yaml/proxy` does not come with the desired format: it misses the protocol and has a port // This method adds the `https` protocol and removes the port if it is the default one for https (443) -func IssuerFromPublicAddress(addr string) (string, error) { +// +// Path is an optional element to append to the issuer to distinguish a +// separate CA within the same cluster. +func IssuerFromPublicAddress(addr string, path string) (string, error) { // Add protocol if not present. if !strings.HasPrefix(addr, "https://") && !strings.HasPrefix(addr, "http://") { addr = "https://" + addr @@ -70,5 +76,9 @@ func IssuerFromPublicAddress(addr string) (string, error) { // Cut off redundant :443 result.Host = result.Hostname() } + + if path != "" { + result.Path = path + } return result.String(), nil } diff --git a/lib/utils/oidc/issuer_test.go b/lib/utils/oidc/issuer_test.go index 51cef0f0c54df..dd360ab937d4c 100644 --- a/lib/utils/oidc/issuer_test.go +++ b/lib/utils/oidc/issuer_test.go @@ -32,6 +32,7 @@ func TestIssuerFromPublicAddress(t *testing.T) { for _, tt := range []struct { name string addr string + path string expected string }{ { @@ -39,11 +40,23 @@ func TestIssuerFromPublicAddress(t *testing.T) { addr: "127.0.0.1.nip.io:3080", expected: "https://127.0.0.1.nip.io:3080", }, + { + name: "valid host:port with path", + addr: "127.0.0.1.nip.io:3080", + path: "/workload-identity", + expected: "https://127.0.0.1.nip.io:3080/workload-identity", + }, { name: "valid ip:port", addr: "127.0.0.1:3080", expected: "https://127.0.0.1:3080", }, + { + name: "valid ip:port with path", + addr: "127.0.0.1:3080", + path: "/workload-identity", + expected: "https://127.0.0.1:3080/workload-identity", + }, { name: "removes 443 port", addr: "https://teleport-local.example.com:443", @@ -54,9 +67,15 @@ func TestIssuerFromPublicAddress(t *testing.T) { addr: "localhost", expected: "https://localhost", }, + { + name: "only host with path", + addr: "localhost", + path: "/workload-identity", + expected: "https://localhost/workload-identity", + }, } { t.Run(tt.name, func(t *testing.T) { - got, err := IssuerFromPublicAddress(tt.addr) + got, err := IssuerFromPublicAddress(tt.addr, tt.path) require.NoError(t, err) require.Equal(t, tt.expected, got) }) @@ -79,6 +98,7 @@ func TestIssuerForCluster(t *testing.T) { ctx := context.Background() for _, tt := range []struct { name string + path string mockProxies []types.Server mockErr error checkErr require.ErrorAssertionFunc @@ -93,6 +113,16 @@ func TestIssuerForCluster(t *testing.T) { }, expectedIssuer: "https://127.0.0.1.nip.io", }, + { + name: "valid with subpath", + path: "/workload-identity", + mockProxies: []types.Server{ + &types.ServerV2{Spec: types.ServerSpecV2{ + PublicAddrs: []string{"127.0.0.1.nip.io"}, + }}, + }, + expectedIssuer: "https://127.0.0.1.nip.io/workload-identity", + }, { name: "only the second server has a valid public address", mockProxies: []types.Server{ @@ -121,7 +151,7 @@ func TestIssuerForCluster(t *testing.T) { proxies: tt.mockProxies, returnErr: tt.mockErr, } - issuer, err := IssuerForCluster(ctx, clt) + issuer, err := IssuerForCluster(ctx, clt, tt.path) if tt.checkErr != nil { tt.checkErr(t, err) } diff --git a/lib/utils/oidc/openidconfig.go b/lib/utils/oidc/openidconfig.go index e4ec68ac15828..7561ab7becfbf 100644 --- a/lib/utils/oidc/openidconfig.go +++ b/lib/utils/oidc/openidconfig.go @@ -19,14 +19,15 @@ package oidc // OpenIDConfiguration is the default OpenID Configuration used by Teleport. +// Based on https://openid.net/specs/openid-connect-discovery-1_0.html type OpenIDConfiguration struct { Issuer string `json:"issuer"` JWKSURI string `json:"jwks_uri"` Claims []string `json:"claims"` IdTokenSigningAlgValuesSupported []string `json:"id_token_signing_alg_values_supported"` ResponseTypesSupported []string `json:"response_types_supported"` - ScopesSupported []string `json:"scopes_supported"` - SubjectTypesSupported []string `json:"subject_types_supported"` + ScopesSupported []string `json:"scopes_supported,omitempty"` + SubjectTypesSupported []string `json:"subject_types_supported,omitempty"` } // OpenIDConfigurationForIssuer returns the OpenID Configuration for diff --git a/lib/web/apiserver.go b/lib/web/apiserver.go index 921abf023213a..2eef54b6eb48e 100644 --- a/lib/web/apiserver.go +++ b/lib/web/apiserver.go @@ -992,6 +992,8 @@ func (h *Handler) bindDefaultEndpoints() { // SPIFFE Federation Trust Bundle h.GET("/webapi/spiffe/bundle.json", h.WithLimiter(h.getSPIFFEBundle)) + h.GET("/workload-identity/jwt-jwks.json", h.WithLimiter(h.getSPIFFEJWKS)) + h.GET("/workload-identity/.well-known/openid-configuration", h.WithLimiter(h.getSPIFFEOIDCDiscoveryDocument)) // DiscoveryConfig CRUD h.GET("/webapi/sites/:site/discoveryconfig", h.WithClusterAuth(h.discoveryconfigList)) @@ -1885,7 +1887,7 @@ func (h *Handler) getUIConfig(ctx context.Context) webclient.UIConfig { // jwks returns all public keys used to sign JWT tokens for this cluster. func (h *Handler) wellKnownJWKS(w http.ResponseWriter, r *http.Request, p httprouter.Params) (interface{}, error) { - return h.jwks(r.Context(), types.JWTSigner) + return h.jwks(r.Context(), types.JWTSigner, true) } func (h *Handler) motd(w http.ResponseWriter, r *http.Request, p httprouter.Params) (interface{}, error) { diff --git a/lib/web/integrations_awsoidc.go b/lib/web/integrations_awsoidc.go index 2ab0a00492d89..74acddd125500 100644 --- a/lib/web/integrations_awsoidc.go +++ b/lib/web/integrations_awsoidc.go @@ -1114,7 +1114,7 @@ func (h *Handler) awsOIDCConfigureIdP(w http.ResponseWriter, r *http.Request, p switch { case s3Bucket == "" && s3Prefix == "": - proxyAddr, err := oidc.IssuerFromPublicAddress(h.cfg.PublicProxyAddr) + proxyAddr, err := oidc.IssuerFromPublicAddress(h.cfg.PublicProxyAddr, "") if err != nil { return nil, trace.Wrap(err) } @@ -1129,7 +1129,7 @@ func (h *Handler) awsOIDCConfigureIdP(w http.ResponseWriter, r *http.Request, p } s3URI := url.URL{Scheme: "s3", Host: s3Bucket, Path: s3Prefix} - jwksContents, err := h.jwks(r.Context(), types.OIDCIdPCA) + jwksContents, err := h.jwks(r.Context(), types.OIDCIdPCA, true) if err != nil { return nil, trace.Wrap(err) } diff --git a/lib/web/integrations_azureoidc.go b/lib/web/integrations_azureoidc.go index 2f92c5d91e381..0ce8d624a79f1 100644 --- a/lib/web/integrations_azureoidc.go +++ b/lib/web/integrations_azureoidc.go @@ -37,7 +37,7 @@ func (h *Handler) azureOIDCConfigure(w http.ResponseWriter, r *http.Request, p h ctx := r.Context() queryParams := r.URL.Query() - oidcIssuer, err := oidc.IssuerFromPublicAddress(h.cfg.PublicProxyAddr) + oidcIssuer, err := oidc.IssuerFromPublicAddress(h.cfg.PublicProxyAddr, "") if err != nil { return nil, trace.Wrap(err) } diff --git a/lib/web/oidcidp.go b/lib/web/oidcidp.go index 028b61d0e349d..28a9b7b7465b0 100644 --- a/lib/web/oidcidp.go +++ b/lib/web/oidcidp.go @@ -38,7 +38,7 @@ const ( // openidConfiguration returns the openid-configuration for setting up the AWS OIDC Integration func (h *Handler) openidConfiguration(_ http.ResponseWriter, _ *http.Request, _ httprouter.Params) (interface{}, error) { - issuer, err := oidc.IssuerFromPublicAddress(h.cfg.PublicProxyAddr) + issuer, err := oidc.IssuerFromPublicAddress(h.cfg.PublicProxyAddr, "") if err != nil { return nil, trace.Wrap(err) } @@ -48,10 +48,10 @@ func (h *Handler) openidConfiguration(_ http.ResponseWriter, _ *http.Request, _ // jwksOIDC returns all public keys used to sign JWT tokens for this cluster. func (h *Handler) jwksOIDC(_ http.ResponseWriter, r *http.Request, _ httprouter.Params) (interface{}, error) { - return h.jwks(r.Context(), types.OIDCIdPCA) + return h.jwks(r.Context(), types.OIDCIdPCA, true) } -func (h *Handler) jwks(ctx context.Context, caType types.CertAuthType) (*JWKSResponse, error) { +func (h *Handler) jwks(ctx context.Context, caType types.CertAuthType, includeBlankKeyID bool) (*JWKSResponse, error) { clusterName, err := h.GetProxyClient().GetDomainName(ctx) if err != nil { return nil, trace.Wrap(err) @@ -82,8 +82,10 @@ func (h *Handler) jwks(ctx context.Context, caType types.CertAuthType) (*JWKSRes // Return an additional copy of the same JWK // with KeyID set to the empty string for compatibility. - jwk.KeyID = "" - resp.Keys = append(resp.Keys, jwk) + if includeBlankKeyID { + jwk.KeyID = "" + resp.Keys = append(resp.Keys, jwk) + } } return &resp, nil } diff --git a/lib/web/spiffe.go b/lib/web/spiffe.go index 865838dc4b560..2a2f937490c58 100644 --- a/lib/web/spiffe.go +++ b/lib/web/spiffe.go @@ -30,6 +30,7 @@ import ( "github.com/gravitational/teleport/lib/jwt" "github.com/gravitational/teleport/lib/services" "github.com/gravitational/teleport/lib/tlsca" + "github.com/gravitational/teleport/lib/utils/oidc" ) // getSPIFFEBundle returns the SPIFFE-compatible trust bundle which allows other @@ -109,3 +110,40 @@ func (h *Handler) getSPIFFEBundle(w http.ResponseWriter, r *http.Request, _ http } return nil, nil } + +// Mounted at /workload-identity/.well-known/openid-configuration +func (h *Handler) getSPIFFEOIDCDiscoveryDocument(_ http.ResponseWriter, _ *http.Request, _ httprouter.Params) (any, error) { + issuer, err := oidc.IssuerFromPublicAddress(h.cfg.PublicProxyAddr, "/workload-identity") + if err != nil { + return nil, trace.Wrap(err, "determining issuer from public address") + } + + return &oidc.OpenIDConfiguration{ + Issuer: issuer, + JWKSURI: issuer + "/jwt-jwks.json", + Claims: []string{ + "iss", + "sub", + "jti", + "aud", + "exp", + "iat", + }, + IdTokenSigningAlgValuesSupported: []string{ + "RS256", + }, + ResponseTypesSupported: []string{ + "id_token", + }, + // Whilst this field is not required for GCP's Workload Identity + // Federation, it is required for AWS's AssumeRoleWithWebIdentity. + SubjectTypesSupported: []string{ + "public", + }, + }, nil +} + +// Mounted at /workload-identity/jwt-jwks.json +func (h *Handler) getSPIFFEJWKS(_ http.ResponseWriter, r *http.Request, _ httprouter.Params) (interface{}, error) { + return h.jwks(r.Context(), types.SPIFFECA, false) +} diff --git a/lib/web/spiffe_test.go b/lib/web/spiffe_test.go index b8e285bf066b9..eef680d411123 100644 --- a/lib/web/spiffe_test.go +++ b/lib/web/spiffe_test.go @@ -22,6 +22,7 @@ import ( "context" "crypto" "crypto/x509" + "encoding/json" "testing" "github.com/gravitational/roundtrip" @@ -83,3 +84,77 @@ func TestGetSPIFFEBundle(t *testing.T) { require.True(t, gotPubKey.(interface{ Equal(x crypto.PublicKey) bool }).Equal(wantKey), "public keys do not match") } } + +// TestSPIFFEJWTPublicEndpoints ensures the public endpoints for the SPIFFE JWT +// OIDC support function correctly. +func TestSPIFFEJWTPublicEndpoints(t *testing.T) { + t.Parallel() + ctx := context.Background() + env := newWebPack(t, 1) + proxy := env.proxies[0] + + // Request OpenID Configuration public endpoint. + publicClt := proxy.newClient(t) + resp, err := publicClt.Get(ctx, proxy.webURL.String()+"/workload-identity/.well-known/openid-configuration", nil) + require.NoError(t, err) + + // Deliberately redefining the structs in this test to assert that the JSON + // representation doesn't unintentionally change. + type oidcConfiguration struct { + Issuer string `json:"issuer"` + JWKSURI string `json:"jwks_uri"` + Claims []string `json:"claims"` + IdTokenSigningAlgValuesSupported []string `json:"id_token_signing_alg_values_supported"` + ResponseTypesSupported []string `json:"response_types_supported"` + } + + var gotConfiguration oidcConfiguration + require.NoError(t, json.Unmarshal(resp.Bytes(), &gotConfiguration)) + + expectedConfiguration := oidcConfiguration{ + Issuer: proxy.webURL.String() + "/workload-identity", + JWKSURI: proxy.webURL.String() + "/workload-identity/jwt-jwks.json", + // OIDC IdPs MUST support RSA256 here. + IdTokenSigningAlgValuesSupported: []string{"RS256"}, + Claims: []string{ + "iss", + "sub", + "jti", + "aud", + "exp", + "iat", + }, + ResponseTypesSupported: []string{"id_token"}, + } + require.Equal(t, expectedConfiguration, gotConfiguration) + + resp, err = publicClt.Get(ctx, gotConfiguration.JWKSURI, nil) + require.NoError(t, err) + + type jwksKey struct { + Use string `json:"use"` + KeyID string `json:"kid"` + KeyType string `json:"kty"` + Alg string `json:"alg"` + } + type jwksKeys struct { + Keys []jwksKey `json:"keys"` + } + gotKeys := jwksKeys{} + err = json.Unmarshal(resp.Bytes(), &gotKeys) + require.NoError(t, err) + + require.Len(t, gotKeys.Keys, 1) + require.NotEmpty(t, gotKeys.Keys[0].KeyID) + expectedKeys := jwksKeys{ + Keys: []jwksKey{ + { + Use: "sig", + KeyType: "EC", + Alg: "ES256", + KeyID: gotKeys.Keys[0].KeyID, + }, + }, + } + require.Equal(t, expectedKeys, gotKeys) +}