Skip to content

Commit

Permalink
support of kubernetes secrets (service catalog) (#25)
Browse files Browse the repository at this point in the history
* aligned with liga on '/etc/secrets/sapbtp/identity' as default path
* use yaml.Unmarshal instead of json.Unmarshal to be more generic when reading file content
* add TODO to use T.SetEnv whenever possible 
* replace deprecated ioutil package with 'io' or 'os'
  • Loading branch information
nenaraab authored Sep 7, 2021
1 parent b053c0f commit 5ad1903
Show file tree
Hide file tree
Showing 20 changed files with 212 additions and 29 deletions.
3 changes: 2 additions & 1 deletion .reuse/dep5
Original file line number Diff line number Diff line change
Expand Up @@ -33,5 +33,6 @@ Files:
.gitignore
.github/**
.golangci.yml
Copyright: 2020 SAP SE or an SAP affiliate company and Cloud Security Client Go contributors
env/testdata/**
Copyright: 2020-2021 SAP SE or an SAP affiliate company and Cloud Security Client Go contributors
License: Apache-2.0
9 changes: 8 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,10 @@
## Description
Client Library in GoLang for application developers requiring authentication with the SAP Identity Authentication Service (IAS). The library provides means for validating the Open ID Connect Token (OIDC) and accessing authentication information like user uuid, user attributes and audiences from the token.

## Supported Environments
- Cloud Foundry
- Kubernetes/Kyma as of 0.11 version

## Requirements
In order to make use of this client library your application should be integrated with the [SAP Identity Authentication Service (IAS)](https://help.sap.com/viewer/6d6d63354d1242d185ab4830fc04feb1/LATEST/en-US/d17a116432d24470930ebea41977a888.html).

Expand All @@ -21,7 +25,10 @@ The client library works as a middleware and has to be instantiated with `NewMid
- Ready-to-use **Middleware Handler**: The `AuthenticationHandler` which implements the standard `http/Handler` interface. Thus, it can be used easily e.g. in an `gorilla/mux` router or a plain `http/Server` implementation. The claims can be retrieved with `auth.GetClaims(req)` in the HTTP handler.
- **Authenticate func**: More flexible, can be wrapped with an own middleware func to propagate the users claims.


### Service configuration in Kubernetes environment
To access service instance configurations from the application, Kubernetes secrets need to be provided as files in a volume mounted on application's container. Library will look up the configuration files on the `mountPath:"/etc/secrets/sapbtp/identity/<YOUR IAS INSTANCE NAME>"`.


### Sample Code

```go
Expand Down
4 changes: 2 additions & 2 deletions auth/middleware_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import (
"context"
"github.com/google/uuid"
"github.com/lestrrat-go/jwx/jwa"
"io/ioutil"
"io"
"net/http"
"net/http/httptest"
"strings"
Expand Down Expand Up @@ -249,7 +249,7 @@ func TestEnd2End(t *testing.T) {
t.Errorf("req to test server succeeded unexpectatly: expected: 401, got: %d", response.StatusCode)
}
}
body, _ := ioutil.ReadAll(response.Body)
body, _ := io.ReadAll(response.Body)
t.Log(string(body))
})
}
Expand Down
4 changes: 2 additions & 2 deletions env/environment.go
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
// SPDX-FileCopyrightText: 2020 SAP SE or an SAP affiliate company and Cloud Security Client Go contributors
// SPDX-FileCopyrightText: 2020-2021 SAP SE or an SAP affiliate company and Cloud Security Client Go contributors
//
// SPDX-License-Identifier: Apache-2.0

Expand All @@ -22,7 +22,7 @@ func getPlatform() Platform {
switch {
case strings.TrimSpace(os.Getenv("VCAP_SERVICES")) != "":
return cloudFoundry
case false: // kubernetes not yet supported
case strings.TrimSpace(os.Getenv("KUBERNETES_SERVICE_HOST")) != "":
return kubernetes
default:
return unknown
Expand Down
99 changes: 94 additions & 5 deletions env/iasConfig.go
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
// SPDX-FileCopyrightText: 2020 SAP SE or an SAP affiliate company and Cloud Security Client Go contributors
// SPDX-FileCopyrightText: 2020-2021 SAP SE or an SAP affiliate company and Cloud Security Client Go contributors
//
// SPDX-License-Identifier: Apache-2.0

Expand All @@ -8,11 +8,16 @@ import (
"encoding/json"
"fmt"
"github.com/google/uuid"
"gopkg.in/yaml.v3"
"os"
"path"
)

const iasServiceName = "identity"
const iasSecretKeyDefault = "credentials"
const vcapServicesEnvKey = "VCAP_SERVICES"
const iasConfigPathKey = "IAS_CONFIG_PATH"
const iasConfigPathDefault = "/etc/secrets/sapbtp/identity"

// VCAPServices is the Cloud Foundry environment variable that stores information about services bound to the application
type VCAPServices struct {
Expand Down Expand Up @@ -46,17 +51,101 @@ func GetIASConfig() (*Identity, error) {
return nil, fmt.Errorf("cannot parse vcap services: %w", err)
}
if len(vcapServices.Identity) == 0 {
return nil, fmt.Errorf("no '" + iasServiceName + "' service instance bound to the application")
return nil, fmt.Errorf("no '%s' service instance bound to the application", iasServiceName)
}
if len(vcapServices.Identity) > 1 {
return nil, fmt.Errorf("more than one '" + iasServiceName + "' service instance bound to the application. This is currently not supported")
return nil, fmt.Errorf("more than one '%s' service instance bound to the application. This is currently not supported", iasServiceName)
}
return &vcapServices.Identity[0].Credentials, nil
case kubernetes:
return nil, fmt.Errorf("unable to parse ias config: kubernetes env detected but not yet supported")
var secretPath = os.Getenv(iasConfigPathKey)
if secretPath == "" {
secretPath = iasConfigPathDefault
}
identities, err := readServiceBindings(secretPath)
if err != nil || len(identities) == 0 {
return nil, fmt.Errorf("cannot find '%s' service binding from secret path '%s'", iasServiceName, secretPath)
} else if len(identities) > 1 {
return nil, fmt.Errorf("found more than one '%s' service instance from secret path '%s'. This is currently not supported", iasServiceName, secretPath)
}
return &identities[0], nil
default:
return nil, fmt.Errorf("unable to parse ias config: unknown environment detected")
return nil, fmt.Errorf("unable to parse '%s' service config: unknown environment detected", iasServiceName)
}
}

func readServiceBindings(secretPath string) ([]Identity, error) {
instancesBound, err := os.ReadDir(secretPath)
if err != nil {
return nil, fmt.Errorf("cannot read service directory '%s' for identity service: %w", secretPath, err)
}
identities := []Identity{}
for _, instanceBound := range instancesBound {
if !instanceBound.IsDir() {
continue
}
serviceInstancePath := path.Join(secretPath, instanceBound.Name())
instanceSecretFiles, err := os.ReadDir(serviceInstancePath)
if err != nil {
return nil, fmt.Errorf("cannot read service instance directory '%s' for '%s' service instance '%s': %w", serviceInstancePath, iasServiceName, instanceBound.Name(), err)
}
instanceSecretsJSON, err := readCredentialsFileToJSON(serviceInstancePath, instanceSecretFiles)
if instanceSecretsJSON == nil || err != nil {
instanceSecretsJSON, err = readSecretFilesToJSON(serviceInstancePath, instanceSecretFiles)
if err != nil {
return nil, err
}
}
identity := Identity{}
if err := json.Unmarshal(instanceSecretsJSON, &identity); err != nil {
return nil, fmt.Errorf("cannot unmarshal json content in directory '%s' for '%s' service instance: %w", serviceInstancePath, iasServiceName, err)
}
identities = append(identities, identity)
}
return identities, nil
}

func readCredentialsFileToJSON(serviceInstancePath string, instanceSecretFiles []os.DirEntry) ([]byte, error) {
for _, instanceSecretFile := range instanceSecretFiles {
if !instanceSecretFile.IsDir() && instanceSecretFile.Name() == iasSecretKeyDefault {
serviceInstanceCredentialsPath := path.Join(serviceInstancePath, instanceSecretFile.Name())
credentials, err := os.ReadFile(serviceInstanceCredentialsPath)
if err != nil {
return nil, fmt.Errorf("cannot read content from '%s': %w", serviceInstanceCredentialsPath, err)
}
if json.Valid(credentials) {
return credentials, nil
}
}
}
return nil, nil
}

func readSecretFilesToJSON(serviceInstancePath string, instanceSecretFiles []os.DirEntry) ([]byte, error) {
instanceCredentialsMap := make(map[string]interface{})
for _, instanceSecretFile := range instanceSecretFiles {
if instanceSecretFile.IsDir() {
continue
}
serviceInstanceSecretPath := path.Join(serviceInstancePath, instanceSecretFile.Name())
var secretContent []byte
secretContent, err := os.ReadFile(serviceInstanceSecretPath)
if err != nil {
return nil, fmt.Errorf("cannot read secret file '%s' from '%s': %w", instanceSecretFile.Name(), serviceInstanceSecretPath, err)
}
var v interface{}
if err := yaml.Unmarshal(secretContent, &v); err == nil {
instanceCredentialsMap[instanceSecretFile.Name()] = v
} else {
fmt.Printf("cannot unmarshal content of secret file '%s' from '%s': %s", instanceSecretFile.Name(), serviceInstanceSecretPath, err)
instanceCredentialsMap[instanceSecretFile.Name()] = string(secretContent)
}
}
instanceCredentialsJSON, err := json.Marshal(instanceCredentialsMap)
if err != nil {
return nil, fmt.Errorf("cannot marshal map into json: %w", err)
}
return instanceCredentialsJSON, nil
}

// GetClientID implements the auth.OAuthConfig interface.
Expand Down
99 changes: 83 additions & 16 deletions env/iasConfig_test.go
Original file line number Diff line number Diff line change
@@ -1,56 +1,99 @@
// SPDX-FileCopyrightText: 2020 SAP SE or an SAP affiliate company and Cloud Security Client Go contributors
// SPDX-FileCopyrightText: 2020-2021 SAP SE or an SAP affiliate company and Cloud Security Client Go contributors
//
// SPDX-License-Identifier: Apache-2.0

package env

import (
"fmt"
"github.com/google/uuid"
"os"
"path"
"reflect"
"testing"
)

var testConfig *Identity = &Identity{
var testConfig = &Identity{
ClientID: "cef76757-de57-480f-be92-1d8c1c7abf16",
ClientSecret: "the_CLIENT.secret:3[/abc",
ClientSecret: "[the_CLIENT.secret:3[/abc",
Domains: []string{"accounts400.ondemand.com", "my.arbitrary.domain"},
URL: "https://mytenant.accounts400.ondemand.com",
ZoneUUID: uuid.MustParse("bef12345-de57-480f-be92-1d8c1c7abf16"),
}

func TestGetIASConfig(t *testing.T) {
tests := []struct {
name string
env string
want *Identity
wantErr bool
name string
k8sSecretPath string
env string
want *Identity
wantErr bool
}{
{
name: "all present",
env: "{\"identity\":[{\"binding_name\":null,\"credentials\":{\"clientid\":\"cef76757-de57-480f-be92-1d8c1c7abf16\",\"clientsecret\":\"the_CLIENT.secret:3[/abc\",\"domains\":[\"accounts400.ondemand.com\",\"my.arbitrary.domain\"],\"token_url\":\"https://mytenant.accounts400.ondemand.com/oauth2/token\",\"url\":\"https://mytenant.accounts400.ondemand.com\"},\"instance_name\":\"my-ams-instance\",\"label\":\"identity\",\"name\":\"my-ams-instance\",\"plan\":\"application\",\"provider\":null,\"syslog_drain_url\":null,\"tags\":[\"ias\"],\"volume_mounts\":[]}]}",
name: "[CF] single identity service instance bound",
env: "{\"identity\":[{\"binding_name\":null,\"credentials\":{\"clientid\":\"cef76757-de57-480f-be92-1d8c1c7abf16\",\"clientsecret\":\"[the_CLIENT.secret:3[/abc\",\"domains\":[\"accounts400.ondemand.com\",\"my.arbitrary.domain\"],\"token_url\":\"https://mytenant.accounts400.ondemand.com/oauth2/token\",\"url\":\"https://mytenant.accounts400.ondemand.com\",\"zone_uuid\":\"bef12345-de57-480f-be92-1d8c1c7abf16\"},\"instance_name\":\"my-ams-instance\",\"label\":\"identity\",\"name\":\"my-ams-instance\",\"plan\":\"application\",\"provider\":null,\"syslog_drain_url\":null,\"tags\":[\"ias\"],\"volume_mounts\":[]}]}",
want: testConfig,
wantErr: false,
},
{
name: "multiple bindings",
env: "{\"identity\":[{\"binding_name\":null,\"credentials\":{\"clientid\":\"cef76757-de57-480f-be92-1d8c1c7abf16\",\"clientsecret\":\"the_CLIENT.secret:3[/abc\",\"domains\":[\"accounts400.ondemand.com\",\"my.arbitrary.domain\"],\"token_url\":\"https://mytenant.accounts400.ondemand.com/oauth2/token\",\"url\":\"https://mytenant.accounts400.ondemand.com\"},\"instance_name\":\"my-ams-instance\",\"label\":\"identity\",\"name\":\"my-ams-instance\",\"plan\":\"application\",\"provider\":null,\"syslog_drain_url\":null,\"tags\":[\"ias\"],\"volume_mounts\":[]},{\"binding_name\":null,\"credentials\":{\"clientid\":\"cef76757-de57-480f-be92-1d8c1c7abf16\",\"clientsecret\":\"the_CLIENT.secret:3[/abc\",\"domain\":\"accounts400.ondemand.com\",\"token_url\":\"https://mytenant.accounts400.ondemand.com/oauth2/token\",\"url\":\"https://mytenant.accounts400.ondemand.com\"},\"instance_name\":\"my-ams-instance\",\"label\":\"identity\",\"name\":\"my-ams-instance\",\"plan\":\"application\",\"provider\":null,\"syslog_drain_url\":null,\"tags\":[\"ias\"],\"volume_mounts\":[]}]}",
name: "[CF] multiple identity service bindings",
env: "{\"identity\":[{\"binding_name\":null,\"credentials\":{\"clientid\":\"cef76757-de57-480f-be92-1d8c1c7abf16\",\"clientsecret\":\"[the_CLIENT.secret:3[/abc\",\"domains\":[\"accounts400.ondemand.com\",\"my.arbitrary.domain\"],\"token_url\":\"https://mytenant.accounts400.ondemand.com/oauth2/token\",\"url\":\"https://mytenant.accounts400.ondemand.com\"},\"instance_name\":\"my-ams-instance\",\"label\":\"identity\",\"name\":\"my-ams-instance\",\"plan\":\"application\",\"provider\":null,\"syslog_drain_url\":null,\"tags\":[\"ias\"],\"volume_mounts\":[]},{\"binding_name\":null,\"credentials\":{\"clientid\":\"cef76757-de57-480f-be92-1d8c1c7abf16\",\"clientsecret\":\"the_CLIENT.secret:3[/abc\",\"domain\":\"accounts400.ondemand.com\",\"token_url\":\"https://mytenant.accounts400.ondemand.com/oauth2/token\",\"url\":\"https://mytenant.accounts400.ondemand.com\"},\"instance_name\":\"my-ams-instance\",\"label\":\"identity\",\"name\":\"my-ams-instance\",\"plan\":\"application\",\"provider\":null,\"syslog_drain_url\":null,\"tags\":[\"ias\"],\"volume_mounts\":[]}]}",
want: nil,
wantErr: true,
},
{
name: "[CF] no identity service binding",
env: "{}",
want: nil,
wantErr: true,
},
{
name: "[K8s] single identity service instance bound",
k8sSecretPath: path.Join("testdata", "k8s", "single-instance"),
want: testConfig,
wantErr: false,
},
{
name: "[K8s] no bindings on default secret path",
k8sSecretPath: "ignore",
want: nil,
wantErr: true,
},
{
name: "[K8s] multiple identity service bindings",
k8sSecretPath: path.Join("testdata", "k8s", "multi-instances"),
want: nil,
wantErr: true,
},
{
name: "[K8s] single identity service instance bound with secretKey=credentials",
k8sSecretPath: path.Join("testdata", "k8s", "single-instance-onecredentialsfile"),
want: testConfig,
wantErr: false,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := setTestEnv(tt.env)
var err error
if tt.env != "" {
err = setTestEnv(tt.env)
} else if tt.k8sSecretPath != "" {
err = setK8sTestEnv(tt.k8sSecretPath)
}
if err != nil {
t.Error(err)
}
got, err := GetIASConfig()
if (err != nil) != tt.wantErr {
t.Errorf("GetIASConfigInUserProvidedService() error = %v, wantErr %v", err, tt.wantErr)
return
if err != nil {
if !tt.wantErr {
t.Errorf("GetIASConfig() error = %v, wantErr:%v", err, tt.wantErr)
return
}
t.Logf("GetIASConfig() error = %v, wantErr:%v", err, tt.wantErr)
}
if !reflect.DeepEqual(got, tt.want) {
t.Errorf("GetIASConfigInUserProvidedService() got = %v, want %v", got, tt.want)
t.Errorf("GetIASConfig() got = %v, want %v", got, tt.want)
}
err = clearTestEnv()
if err != nil {
Expand All @@ -60,6 +103,8 @@ func TestGetIASConfig(t *testing.T) {
}
}

// TODO go 1.17 supports T.SetEnv https://pkg.go.dev/testing#T.Setenv
// Cleanup when go 1.18 is released
func setTestEnv(vcapServices string) error {
err := os.Setenv("VCAP_SERVICES", vcapServices)
if err != nil {
Expand All @@ -72,6 +117,20 @@ func setTestEnv(vcapServices string) error {
return nil
}

func setK8sTestEnv(secretPath string) error {
err := os.Setenv("KUBERNETES_SERVICE_HOST", "0.0.0.0")
if err != nil {
return fmt.Errorf("error preparing test: could not set env KUBERNETES_SERVICE_HOST: %w", err)
}
if secretPath != "" && secretPath != "ignore" {
err = os.Setenv("IAS_CONFIG_PATH", secretPath)
if err != nil {
return fmt.Errorf("error preparing test: could not set env IAS_CONFIG_PATH: %w", err)
}
}
return nil
}

func clearTestEnv() error {
err := os.Unsetenv("VCAP_SERVICES")
if err != nil {
Expand All @@ -81,5 +140,13 @@ func clearTestEnv() error {
if err != nil {
return fmt.Errorf("error cleaning up after test: could not unset env VCAP_APPLICATION/VCAP_SERVICES: %w", err)
}
err = os.Unsetenv("KUBERNETES_SERVICE_HOST")
if err != nil {
return fmt.Errorf("error cleaning up after test: could not unset env KUBERNETES_SERVICE_HOST: %w", err)
}
err = os.Unsetenv("IAS_CONFIG_PATH")
if err != nil {
return fmt.Errorf("error cleaning up after test: could not unset env IAS_CONFIG_PATH: %w", err)
}
return nil
}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
cef76757-de57-480f-be92-1d8c1c7abf16
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
cef76757-de57-480f-be92-1d8c1c7abf16
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
"clientsecret": "[the_CLIENT.secret:3[/abc",
"clientid": "cef76757-de57-480f-be92-1d8c1c7abf16",
"domains": [
"accounts400.ondemand.com", "my.arbitrary.domain"
],
"url": "https://mytenant.accounts400.ondemand.com",
"zone_uuid": "bef12345-de57-480f-be92-1d8c1c7abf16"
}
Empty file.
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
cef12345-ed75-fabc-be92-1d8c1c7abf16
1 change: 1 addition & 0 deletions env/testdata/k8s/single-instance/service-instance/clientid
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
cef76757-de57-480f-be92-1d8c1c7abf16
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
[the_CLIENT.secret:3[/abc
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
These "credentials" content shall be ignored!
1 change: 1 addition & 0 deletions env/testdata/k8s/single-instance/service-instance/domains
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
["accounts400.ondemand.com", "my.arbitrary.domain"]
Empty file.
1 change: 1 addition & 0 deletions env/testdata/k8s/single-instance/service-instance/url
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
https://mytenant.accounts400.ondemand.com
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
bef12345-de57-480f-be92-1d8c1c7abf16
1 change: 1 addition & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -10,4 +10,5 @@ require (
github.com/patrickmn/go-cache v2.1.0+incompatible
github.com/pquerna/cachecontrol v0.1.0
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c
)
Loading

0 comments on commit 5ad1903

Please sign in to comment.