diff --git a/opentdf-dev.yaml b/opentdf-dev.yaml index c095a3cbbe..f3a9dbfd7b 100644 --- a/opentdf-dev.yaml +++ b/opentdf-dev.yaml @@ -35,6 +35,7 @@ server: auth: enabled: true enforceDPoP: false + public_client_id: 'opentdf-public' audience: 'http://localhost:8080' issuer: http://localhost:8888/auth/realms/opentdf policy: @@ -75,10 +76,10 @@ server: # m = g(r.sub, p.sub) && globOrRegexMatch(r.res, p.res) && globOrRegexMatch(r.act, p.act) && globOrRegexMatch(r.obj, p.obj) cors: enabled: false - # '*' to allow any origin or a specific domain like 'https://yourdomain.com' + # "*" to allow any origin or a specific domain like "https://yourdomain.com" allowedorigins: - '*' - # List of methods. Examples: 'GET,POST,PUT' + # List of methods. Examples: "GET,POST,PUT" allowedmethods: - GET - POST diff --git a/opentdf-example-no-kas.yaml b/opentdf-example-no-kas.yaml index 7d1849a18b..aee0b09250 100644 --- a/opentdf-example-no-kas.yaml +++ b/opentdf-example-no-kas.yaml @@ -13,14 +13,15 @@ server: auth: enabled: false enforceDPoP: false - audience: "http://localhost:8080" + public_client_id: 'opentdf-public' + audience: 'http://localhost:8080' issuer: http://localhost:8888/auth/realms/tdf cors: enabled: false - # '*' to allow any origin or a specific domain like 'https://yourdomain.com' - allowedorigins: - - "*" - # List of methods. Examples: 'GET,POST,PUT' + # "*" to allow any origin or a specific domain like "https://yourdomain.com" + allowedorigins: + - '*' + # List of methods. Examples: "GET,POST,PUT" allowedmethods: - GET - POST diff --git a/opentdf-example.yaml b/opentdf-example.yaml index 7c765e80ec..8050abcfaa 100644 --- a/opentdf-example.yaml +++ b/opentdf-example.yaml @@ -8,16 +8,16 @@ db: # port: 5432 # user: postgres # password: changeme -# mode: all +# mode: all services: kas: eccertid: e1 rsacertid: r1 entityresolution: url: http://keycloak:8888/auth - clientid: "tdf-entity-resolution" - clientsecret: "secret" - realm: "opentdf" + clientid: 'tdf-entity-resolution' + clientsecret: 'secret' + realm: 'opentdf' legacykeycloak: true inferid: from: @@ -27,7 +27,8 @@ server: auth: enabled: true enforceDPoP: false - audience: "http://localhost:8080" + public_client_id: 'opentdf-public' + audience: 'http://localhost:8080' issuer: http://keycloak:8888/auth/realms/opentdf policy: ## Default policy for all requests @@ -66,10 +67,10 @@ server: # m = g(r.sub, p.sub) && globOrRegexMatch(r.res, p.res) && globOrRegexMatch(r.act, p.act) && globOrRegexMatch(r.obj, p.obj) cors: enabled: false - # '*' to allow any origin or a specific domain like 'https://yourdomain.com' - allowedorigins: - - "*" - # List of methods. Examples: 'GET,POST,PUT' + # "*" to allow any origin or a specific domain like "https://yourdomain.com" + allowedorigins: + - '*' + # List of methods. Examples: "GET,POST,PUT" allowedmethods: - GET - POST diff --git a/opentdf-with-hsm.yaml b/opentdf-with-hsm.yaml index 3bde88b14d..8ac69e9c71 100644 --- a/opentdf-with-hsm.yaml +++ b/opentdf-with-hsm.yaml @@ -18,9 +18,9 @@ services: entityresolution: enabled: true url: http://localhost:8888/auth - clientid: "tdf-entity-resolution" - clientsecret: "secret" - realm: "opentdf" + clientid: 'tdf-entity-resolution' + clientsecret: 'secret' + realm: 'opentdf' legacykeycloak: true authorization: enabled: true @@ -28,11 +28,12 @@ server: auth: enabled: true enforceDPoP: false - audience: "http://localhost:8080" + public_client_id: 'opentdf-public' + audience: 'http://localhost:8080' issuer: http://localhost:8888/auth/realms/opentdf clients: - - "opentdf" - - "opentdf-sdk" + - 'opentdf' + - 'opentdf-sdk' policy: ## Default policy for all requests default: #"role:standard" @@ -72,10 +73,10 @@ server: # m = g(r.sub, p.sub) && globOrRegexMatch(r.res, p.res) && globOrRegexMatch(r.act, p.act) && globOrRegexMatch(r.obj, p.obj) cors: enabled: false - # '*' to allow any origin or a specific domain like 'https://yourdomain.com' - allowedorigins: - - "*" - # List of methods. Examples: 'GET,POST,PUT' + # "*" to allow any origin or a specific domain like "https://yourdomain.com" + allowedorigins: + - '*' + # List of methods. Examples: "GET,POST,PUT" allowedmethods: - GET - POST @@ -102,8 +103,8 @@ server: type: hsm hsm: # As configured by init-temp-keys.sh --hsm - pin: "12345" - slotlabel: "dev-token" + pin: '12345' + slotlabel: 'dev-token' keys: - kid: r1 alg: rsa:2048 diff --git a/sdk/options.go b/sdk/options.go index 88daabac90..c7856563ae 100644 --- a/sdk/options.go +++ b/sdk/options.go @@ -16,6 +16,8 @@ type Option func(*config) // Internal config struct for building SDK options. type config struct { + // Platform configuration structure is subject to change. Consume via accessor methods. + PlatformConfiguration PlatformConfiguration dialOption grpc.DialOption tlsConfig *tls.Config clientCredentials *oauth.ClientCredentials @@ -24,7 +26,6 @@ type config struct { scopes []string extraDialOptions []grpc.DialOption certExchange *oauth.CertExchangeInfo - platformConfiguration PlatformConfiguration kasSessionKey *ocrypto.RsaKeyPair dpopKey *ocrypto.RsaKeyPair ipc bool @@ -164,7 +165,7 @@ func WithCustomWellknownConnection(conn *grpc.ClientConn) Option { // Use this option with caution, as it may lead to unexpected behavior func WithPlatformConfiguration(platformConfiguration PlatformConfiguration) Option { return func(c *config) { - c.platformConfiguration = platformConfiguration + c.PlatformConfiguration = platformConfiguration } } diff --git a/sdk/platformconfig.go b/sdk/platformconfig.go index 572301e40d..9afe05fc61 100644 --- a/sdk/platformconfig.go +++ b/sdk/platformconfig.go @@ -1,19 +1,54 @@ package sdk -import "log/slog" +import ( + "log/slog" +) -func (s SDK) PlatformIssuer() string { - // This check is needed if we want to fetch platform configuration over ipc - if s.config.platformConfiguration == nil { - cfg, err := getPlatformConfiguration(s.conn) - if err != nil { - slog.Warn("failed to get platform configuration", slog.Any("error", err)) - } - s.config.platformConfiguration = cfg +func (c PlatformConfiguration) getIdpConfig() map[string]interface{} { + idpCfg, err := c["idp"].(map[string]interface{}) + if !err { + slog.Warn("idp configuration not found in well-known configuration") + idpCfg = map[string]interface{}{} } - value, ok := s.config.platformConfiguration["platform_issuer"].(string) + return idpCfg +} + +func (c PlatformConfiguration) Issuer() (string, error) { + idpCfg := c.getIdpConfig() + value, ok := idpCfg["issuer"].(string) + if !ok { + slog.Warn("issuer not found in well-known idp configuration") + return "", ErrPlatformIssuerNotFound + } + return value, nil +} + +func (c PlatformConfiguration) AuthzEndpoint() (string, error) { + idpCfg := c.getIdpConfig() + value, ok := idpCfg["authorization_endpoint"].(string) + if !ok { + slog.Warn("authorization_endpoint not found in well-known idp configuration") + return "", ErrPlatformAuthzEndpointNotFound + } + return value, nil +} + +func (c PlatformConfiguration) TokenEndpoint() (string, error) { + idpCfg := c.getIdpConfig() + value, ok := idpCfg["token_endpoint"].(string) + if !ok { + slog.Warn("token_endpoint not found in well-known idp configuration") + return "", ErrPlatformTokenEndpointNotFound + } + return value, nil +} + +func (c PlatformConfiguration) PublicClientID() (string, error) { + idpCfg := c.getIdpConfig() + value, ok := idpCfg["public_client_id"].(string) if !ok { - slog.Warn("platform_issuer not found in platform configuration") + slog.Warn("public_client_id not found in well-known idp configuration") + return "", ErrPlatformTokenEndpointNotFound } - return value + return value, nil } diff --git a/sdk/sdk.go b/sdk/sdk.go index 0e4fd01812..8c7d7f2b96 100644 --- a/sdk/sdk.go +++ b/sdk/sdk.go @@ -8,7 +8,6 @@ import ( "errors" "fmt" "io" - "log/slog" "net" "net/http" "net/url" @@ -35,10 +34,14 @@ import ( const ( // Failure while connecting to a service. // Check your configuration and/or retry. - ErrGrpcDialFailed = Error("failed to dial grpc endpoint") - ErrShutdownFailed = Error("failed to shutdown sdk") - ErrPlatformConfigFailed = Error("failed to retrieve platform configuration") - ErrPlatformEndpointMalformed = Error("platform endpoint is malformed") + ErrGrpcDialFailed = Error("failed to dial grpc endpoint") + ErrShutdownFailed = Error("failed to shutdown sdk") + ErrPlatformConfigFailed = Error("failed to retrieve platform configuration") + ErrPlatformEndpointMalformed = Error("platform endpoint is malformed") + ErrPlatformIssuerNotFound = Error("issuer not found in well-known idp configuration") + ErrPlatformAuthzEndpointNotFound = Error("authorization_endpoint not found in well-known idp configuration") + ErrPlatformTokenEndpointNotFound = Error("token_endpoint not found in well-known idp configuration") + ErrPlatformPublicClientIDNotFound = Error("public_client_id not found in well-known idp configuration") ) type Error string @@ -112,7 +115,7 @@ func New(platformEndpoint string, opts ...Option) (*SDK, error) { } // If platformConfiguration is not provided, fetch it from the platform - if cfg.platformConfiguration == nil && !cfg.ipc { //nolint:nestif // Most of checks are for errors + if cfg.PlatformConfiguration == nil && !cfg.ipc { //nolint:nestif // Most of checks are for errors var pcfg PlatformConfiguration var err error @@ -127,7 +130,7 @@ func New(platformEndpoint string, opts ...Option) (*SDK, error) { return nil, errors.Join(ErrPlatformConfigFailed, err) } } - cfg.platformConfiguration = pcfg + cfg.PlatformConfiguration = pcfg if cfg.tokenEndpoint == "" { cfg.tokenEndpoint, err = getTokenEndpoint(*cfg) if err != nil { @@ -212,14 +215,9 @@ func buildIDPTokenSource(c *config) (auth.AccessTokenSource, error) { return c.customAccessTokenSource, nil } - if (c.clientCredentials == nil) != (c.tokenEndpoint == "") { - return nil, errors.New("either both or neither of client credentials and token endpoint must be specified") - } - - // at this point we have either both client credentials and a token endpoint or none of the above. if we don't have - // any just return a KAS client that can only get public keys + // If we don't have client-credentials, just return a KAS client that can only get public keys. + // There are uses for uncredentialed clients (i.e. consuming the well-known configuration). if c.clientCredentials == nil { - slog.Info("no client credentials provided. GRPC requests to KAS and services will not be authenticated.") return nil, nil //nolint:nilnil // not having credentials is not an error } @@ -336,7 +334,6 @@ func IsValidTdf(reader io.ReadSeeker) (bool, error) { loader := gojsonschema.NewStringLoader(manifestSchemaString) manifestStringLoader := gojsonschema.NewStringLoader(manifest) result, err := gojsonschema.Validate(loader, manifestStringLoader) - if err != nil { return false, errors.New("could not validate manifest.json") } @@ -383,7 +380,7 @@ func getPlatformConfiguration(conn *grpc.ClientConn) (PlatformConfiguration, err // TODO: This should be moved to a separate package. We do discovery in ../service/internal/auth/discovery.go func getTokenEndpoint(c config) (string, error) { - issuerURL, ok := c.platformConfiguration["platform_issuer"].(string) + issuerURL, ok := c.PlatformConfiguration["platform_issuer"].(string) if !ok { return "", errors.New("platform_issuer is not set, or is not a string") diff --git a/sdk/sdk_test.go b/sdk/sdk_test.go index 454aab0553..3f4e35b361 100644 --- a/sdk/sdk_test.go +++ b/sdk/sdk_test.go @@ -32,7 +32,12 @@ func GetMethods(i interface{}) []string { func TestNew_ShouldCreateSDK(t *testing.T) { s, err := sdk.New(goodPlatformEndpoint, sdk.WithPlatformConfiguration(sdk.PlatformConfiguration{ - "platform_issuer": "https://example.org", + "idp": map[string]interface{}{ + "issuer": "https://example.org", + "authorization_endpoint": "https://example.org/auth", + "token_endpoint": "https://example.org/token", + "public_client_id": "myclient", + }, }), sdk.WithClientCredentials("myid", "mysecret", nil), sdk.WithTokenEndpoint("https://example.org/token"), @@ -41,7 +46,24 @@ func TestNew_ShouldCreateSDK(t *testing.T) { require.NotNil(t, s) // Check platform issuer - assert.Equal(t, "https://example.org", s.PlatformIssuer()) + iss, err := s.PlatformConfiguration.Issuer() + assert.Equal(t, "https://example.org", iss) + require.NoError(t, err) + + // Check platform authz endpoint + authzEndpoint, err := s.PlatformConfiguration.AuthzEndpoint() + assert.Equal(t, "https://example.org/auth", authzEndpoint) + require.NoError(t, err) + + // Check platform token endpoint + tokenEndpoint, err := s.PlatformConfiguration.TokenEndpoint() + assert.Equal(t, "https://example.org/token", tokenEndpoint) + require.NoError(t, err) + + // Check platform public client id + publicClientID, err := s.PlatformConfiguration.PublicClientID() + assert.Equal(t, "myclient", publicClientID) + require.NoError(t, err) // check if the clients are available assert.NotNil(t, s.Attributes) @@ -50,6 +72,44 @@ func TestNew_ShouldCreateSDK(t *testing.T) { assert.NotNil(t, s.KeyAccessServerRegistry) } +func Test_PlatformConfiguration_BadCases(t *testing.T) { + assertions := func(t *testing.T, s *sdk.SDK) { + iss, err := s.PlatformConfiguration.Issuer() + assert.Equal(t, "", iss) + require.ErrorIs(t, err, sdk.ErrPlatformIssuerNotFound) + + authzEndpoint, err := s.PlatformConfiguration.AuthzEndpoint() + assert.Equal(t, "", authzEndpoint) + require.ErrorIs(t, err, sdk.ErrPlatformAuthzEndpointNotFound) + + tokenEndpoint, err := s.PlatformConfiguration.TokenEndpoint() + assert.Equal(t, "", tokenEndpoint) + require.ErrorIs(t, err, sdk.ErrPlatformTokenEndpointNotFound) + + publicClientID, err := s.PlatformConfiguration.PublicClientID() + assert.Equal(t, "", publicClientID) + require.ErrorIs(t, err, sdk.ErrPlatformTokenEndpointNotFound) + } + + noIdpValsSDK, err := sdk.New(goodPlatformEndpoint, + sdk.WithPlatformConfiguration(sdk.PlatformConfiguration{ + "idp": map[string]interface{}{}, + }), + ) + require.NoError(t, err) + assert.NotNil(t, noIdpValsSDK) + + assertions(t, noIdpValsSDK) + + noIdpCfgSDK, err := sdk.New(goodPlatformEndpoint, + sdk.WithPlatformConfiguration(sdk.PlatformConfiguration{}), + ) + require.NoError(t, err) + assert.NotNil(t, noIdpCfgSDK) + + assertions(t, noIdpCfgSDK) +} + func Test_ShouldCreateNewSDK_NoCredentials(t *testing.T) { // When s, err := sdk.New(goodPlatformEndpoint, diff --git a/sdk/tdf_test.go b/sdk/tdf_test.go index 29f44f10fb..2aee76969c 100644 --- a/sdk/tdf_test.go +++ b/sdk/tdf_test.go @@ -260,7 +260,6 @@ func TestTDF(t *testing.T) { func (s *TDFSuite) Test_SimpleTDF() { metaData := []byte(`{"displayName" : "openTDF go sdk"}`) attributes := []string{ - "https://example.com/attr/Classification/value/S", "https://example.com/attr/Classification/value/X", } @@ -1218,7 +1217,9 @@ func (s *TDFSuite) startBackend() { "health": map[string]interface{}{ "endpoint": "/healthz", }, - "platform_issuer": "http://localhost:65432/auth", + "idp": map[string]interface{}{ + "issuer": "http://localhost:65432/auth", + }, }, } @@ -1268,8 +1269,10 @@ func (s *TDFSuite) startBackend() { listeners[origin] = grpcListener grpcServer := grpc.NewServer() - s.kases[i] = FakeKas{s: s, privateKey: ki.private, KASInfo: KASInfo{ - URL: ki.url, PublicKey: ki.public, KID: "r1", Algorithm: "rsa:2048"}, + s.kases[i] = FakeKas{ + s: s, privateKey: ki.private, KASInfo: KASInfo{ + URL: ki.url, PublicKey: ki.public, KID: "r1", Algorithm: "rsa:2048", + }, legakeys: map[string]keyInfo{}, } attributespb.RegisterAttributesServiceServer(grpcServer, fa) @@ -1365,6 +1368,7 @@ func mockAttributeFor(fqn autoconfigure.AttributeNameFQN) *policy.Attribute { } return nil } + func mockValueFor(fqn autoconfigure.AttributeValueFQN) *policy.Value { an := fqn.Prefix() a := mockAttributeFor(an) diff --git a/service/cmd/keycloak_data.yaml b/service/cmd/keycloak_data.yaml index 42121366b0..006a326b27 100644 --- a/service/cmd/keycloak_data.yaml +++ b/service/cmd/keycloak_data.yaml @@ -71,6 +71,16 @@ realms: secret: secret protocolMappers: - *customAudMapper + - client: + clientID: opentdf-public + enabled: true + name: opentdf-public + serviceAccountsEnabled: false + publicClient: true + redirectUris: + - 'http://localhost:9000/*' # otdfctl CLI tool + protocolMappers: + - *customAudMapper users: - username: sample-user enabled: true diff --git a/service/cmd/provisionKeycloak.go b/service/cmd/provisionKeycloak.go index 194be731d1..9dc4d7d0f8 100644 --- a/service/cmd/provisionKeycloak.go +++ b/service/cmd/provisionKeycloak.go @@ -66,7 +66,7 @@ var provisionKeycloakCmd = &cobra.Command{ }, } -var provisionKeycloakFromConfigCmd = &cobra.Command{ +var deprecatedProvisionKeycloakFromConfigCmd = &cobra.Command{ Use: "keycloak-from-config", RunE: func(_ *cobra.Command, _ []string) error { slog.Info("Command keycloak-from-config has been deprecated. Please use command 'keycloak' instead.") @@ -130,5 +130,5 @@ func init() { provisionCmd.AddCommand(provisionKeycloakCmd) // Deprecated command - provisionCmd.AddCommand(provisionKeycloakFromConfigCmd) + provisionCmd.AddCommand(deprecatedProvisionKeycloakFromConfigCmd) } diff --git a/service/internal/auth/authn.go b/service/internal/auth/authn.go index cf1dc47bd4..cf8cdcef2f 100644 --- a/service/internal/auth/authn.go +++ b/service/internal/auth/authn.go @@ -114,6 +114,8 @@ func NewAuthenticator(ctx context.Context, cfg Config, logger *logger.Logger, we if err != nil { return nil, err } + // Assign configured public_client_id + oidcConfig.PublicClientID = cfg.PublicClientID // If the issuer is different from the one in the configuration, update the configuration // This could happen if we are hitting an internal endpoint. Example we might point to https://keycloak.opentdf.svc/realms/opentdf diff --git a/service/internal/auth/config.go b/service/internal/auth/config.go index cf9fd2b8a5..f50d0e3c5a 100644 --- a/service/internal/auth/config.go +++ b/service/internal/auth/config.go @@ -16,13 +16,14 @@ type Config struct { // AuthNConfig is the configuration need for the platform to validate tokens type AuthNConfig struct { //nolint:revive // AuthNConfig is a valid name - EnforceDPoP bool `yaml:"enforceDPoP" json:"enforceDPoP" mapstructure:"enforceDPoP" default:"false"` - Issuer string `yaml:"issuer" json:"issuer"` - Audience string `yaml:"audience" json:"audience"` - Policy PolicyConfig `yaml:"policy" json:"policy" mapstructure:"policy"` - CacheRefresh string `mapstructure:"cache_refresh_interval"` - DPoPSkew time.Duration `mapstructure:"dpopskew" default:"1h"` - TokenSkew time.Duration `mapstructure:"skew" default:"1m"` + EnforceDPoP bool `yaml:"enforceDPoP" json:"enforceDPoP" mapstructure:"enforceDPoP" default:"false"` + Issuer string `yaml:"issuer" json:"issuer"` + Audience string `yaml:"audience" json:"audience"` + Policy PolicyConfig `yaml:"policy" json:"policy" mapstructure:"policy"` + CacheRefresh string `mapstructure:"cache_refresh_interval"` + DPoPSkew time.Duration `mapstructure:"dpopskew" default:"1h"` + TokenSkew time.Duration `mapstructure:"skew" default:"1m"` + PublicClientID string `yaml:"public_client_id" json:"public_client_id,omitempty" mapstructure:"public_client_id"` } type PolicyConfig struct { @@ -42,6 +43,10 @@ func (c AuthNConfig) validateAuthNConfig(logger *logger.Logger) error { return fmt.Errorf("config Auth.Audience is required") } + if c.PublicClientID == "" { + logger.Warn("config Auth.PublicClientID is empty and is required for discovery via well-known configuration.") + } + if !c.EnforceDPoP { logger.Warn("config Auth.EnforceDPoP is false. DPoP will not be enforced.") } diff --git a/service/internal/auth/discovery.go b/service/internal/auth/discovery.go index 9cbff5a3b7..e77988626a 100644 --- a/service/internal/auth/discovery.go +++ b/service/internal/auth/discovery.go @@ -26,6 +26,7 @@ type OIDCConfiguration struct { SubjectTypesSupported []string `json:"subject_types_supported"` IDTokenSigningAlgValuesSupported []string `json:"id_token_signing_alg_values_supported"` RequireRequestURIRegistration bool `json:"require_request_uri_registration"` + PublicClientID string `json:"public_client_id,omitempty"` } // DiscoverOPENIDConfiguration discovers the openid configuration for the issuer provided