diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 48690cf..66d68b6 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -27,7 +27,7 @@ jobs: with: go-version: ${{ matrix.go }} - run: go test -race -v -coverprofile=profile.cov -coverpkg=./pkg/... ./pkg/... - - uses: codecov/codecov-action@v4.5.0 + - uses: codecov/codecov-action@v4.6.0 with: file: ./profile.cov name: codecov-go diff --git a/README.md b/README.md index ade2e35..96f1c22 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,7 @@ Whenever possible we tried to reuse / extend existing packages like `OAuth2 for The most important packages of the library:
 /pkg
-    /client            clients using the OP for retrieving, exchanging and verifying tokens       
+    /client            clients using the OP for retrieving, exchanging and verifying tokens
         /rp            definition and implementation of an OIDC Relying Party (client)
         /rs            definition and implementation of an OAuth Resource Server (API)
     /op                definition and implementation of an OIDC OpenID Provider (server)
@@ -55,14 +55,14 @@ CLIENT_ID=web CLIENT_SECRET=secret ISSUER=http://localhost:9998/ SCOPES="openid
 ```
 
 - open http://localhost:9999/login in your browser
-- you will be redirected to op server and the login UI 
+- you will be redirected to op server and the login UI
 - login with user `test-user@localhost` and password `verysecure`
 - the OP will redirect you to the client app, which displays the user info
 
 for the dynamic issuer, just start it with:
 ```bash
 go run github.com/zitadel/oidc/v3/example/server/dynamic
-``` 
+```
 the oidc web client above will still work, but if you add `oidc.local` (pointing to 127.0.0.1) in your hosts file you can also start it with:
 ```bash
 CLIENT_ID=web CLIENT_SECRET=secret ISSUER=http://oidc.local:9998/ SCOPES="openid profile" PORT=9999 go run github.com/zitadel/oidc/v3/example/client/app
@@ -70,6 +70,36 @@ CLIENT_ID=web CLIENT_SECRET=secret ISSUER=http://oidc.local:9998/ SCOPES="openid
 
 > Note: Usernames are suffixed with the hostname (`test-user@localhost` or `test-user@oidc.local`)
 
+### Server configuration
+
+Example server allows extra configuration using environment variables and could be used for end to
+end testing of your services.
+
+| Name          | Format                               | Description                           |
+|---------------|--------------------------------------|---------------------------------------|
+| PORT          | Number between 1 and 65535           | OIDC listen port                      |
+| REDIRECT_URI  | Comma-separated URIs                 | List of allowed redirect URIs         |
+| USERS_FILE    | Path to json in local filesystem     | Users with their data and credentials |
+
+Here is json equivalent for one of the default users
+```json
+{
+    "id2": {
+        "ID":                "id2",
+        "Username":          "test-user2",
+        "Password":          "verysecure",
+        "FirstName":         "Test",
+        "LastName":          "User2",
+        "Email":             "test-user2@zitadel.ch",
+        "EmailVerified":     true,
+        "Phone":             "",
+        "PhoneVerified":     false,
+        "PreferredLanguage": "DE",
+        "IsAdmin":           false
+    }
+}
+```
+
 ## Features
 
 |                      | Relying party | OpenID Provider | Specification                             |
@@ -117,7 +147,7 @@ For your convenience you can find the relevant guides linked below.
 
 ## Supported Go Versions
 
-For security reasons, we only support and recommend the use of one of the latest two Go versions (:white_check_mark:).  
+For security reasons, we only support and recommend the use of one of the latest two Go versions (:white_check_mark:).
 Versions that also build are marked with :warning:.
 
 | Version | Supported          |
diff --git a/example/server/config/config.go b/example/server/config/config.go
new file mode 100644
index 0000000..96837d4
--- /dev/null
+++ b/example/server/config/config.go
@@ -0,0 +1,40 @@
+package config
+
+import (
+	"os"
+	"strings"
+)
+
+const (
+	// default port for the http server to run
+	DefaultIssuerPort = "9998"
+)
+
+type Config struct {
+	Port        string
+	RedirectURI []string
+	UsersFile   string
+}
+
+// FromEnvVars loads configuration parameters from environment variables.
+// If there is no such variable defined, then use default values.
+func FromEnvVars(defaults *Config) *Config {
+	if defaults == nil {
+		defaults = &Config{}
+	}
+	cfg := &Config{
+		Port:        defaults.Port,
+		RedirectURI: defaults.RedirectURI,
+		UsersFile:   defaults.UsersFile,
+	}
+	if value, ok := os.LookupEnv("PORT"); ok {
+		cfg.Port = value
+	}
+	if value, ok := os.LookupEnv("USERS_FILE"); ok {
+		cfg.UsersFile = value
+	}
+	if value, ok := os.LookupEnv("REDIRECT_URI"); ok {
+		cfg.RedirectURI = strings.Split(value, ",")
+	}
+	return cfg
+}
diff --git a/example/server/config/config_test.go b/example/server/config/config_test.go
new file mode 100644
index 0000000..3b73c0b
--- /dev/null
+++ b/example/server/config/config_test.go
@@ -0,0 +1,77 @@
+package config
+
+import (
+	"fmt"
+	"os"
+	"testing"
+)
+
+func TestFromEnvVars(t *testing.T) {
+
+	for _, tc := range []struct {
+		name     string
+		env      map[string]string
+		defaults *Config
+		want     *Config
+	}{
+		{
+			name: "no vars, no default values",
+			env:  map[string]string{},
+			want: &Config{},
+		},
+		{
+			name: "no vars, only defaults",
+			env:  map[string]string{},
+			defaults: &Config{
+				Port:        "6666",
+				UsersFile:   "/default/user/path",
+				RedirectURI: []string{"re", "direct", "uris"},
+			},
+			want: &Config{
+				Port:        "6666",
+				UsersFile:   "/default/user/path",
+				RedirectURI: []string{"re", "direct", "uris"},
+			},
+		},
+		{
+			name: "overriding default values",
+			env: map[string]string{
+				"PORT":         "1234",
+				"USERS_FILE":   "/path/to/users",
+				"REDIRECT_URI": "http://redirect/redirect",
+			},
+			defaults: &Config{
+				Port:        "6666",
+				UsersFile:   "/default/user/path",
+				RedirectURI: []string{"re", "direct", "uris"},
+			},
+			want: &Config{
+				Port:        "1234",
+				UsersFile:   "/path/to/users",
+				RedirectURI: []string{"http://redirect/redirect"},
+			},
+		},
+		{
+			name: "multiple redirect uris",
+			env: map[string]string{
+				"REDIRECT_URI": "http://host_1,http://host_2,http://host_3",
+			},
+			want: &Config{
+				RedirectURI: []string{
+					"http://host_1", "http://host_2", "http://host_3",
+				},
+			},
+		},
+	} {
+		t.Run(tc.name, func(t *testing.T) {
+			os.Clearenv()
+			for k, v := range tc.env {
+				os.Setenv(k, v)
+			}
+			cfg := FromEnvVars(tc.defaults)
+			if fmt.Sprint(cfg) != fmt.Sprint(tc.want) {
+				t.Errorf("Expected FromEnvVars()=%q, but got %q", tc.want, cfg)
+			}
+		})
+	}
+}
diff --git a/example/server/main.go b/example/server/main.go
index da8e73f..36816d6 100644
--- a/example/server/main.go
+++ b/example/server/main.go
@@ -5,20 +5,33 @@ import (
 	"log/slog"
 	"net/http"
 	"os"
-	"strings"
 
+	"github.com/zitadel/oidc/v3/example/server/config"
 	"github.com/zitadel/oidc/v3/example/server/exampleop"
 	"github.com/zitadel/oidc/v3/example/server/storage"
 )
 
+func getUserStore(cfg *config.Config) (storage.UserStore, error) {
+	if cfg.UsersFile == "" {
+		return storage.NewUserStore(fmt.Sprintf("http://localhost:%s/", cfg.Port)), nil
+	}
+	return storage.StoreFromFile(cfg.UsersFile)
+}
+
 func main() {
-	//we will run on :9998
-	port := "9998"
+	cfg := config.FromEnvVars(&config.Config{Port: "9998"})
+	logger := slog.New(
+		slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{
+			AddSource: true,
+			Level:     slog.LevelDebug,
+		}),
+	)
+
 	//which gives us the issuer: http://localhost:9998/
-	issuer := fmt.Sprintf("http://localhost:%s/", port)
+	issuer := fmt.Sprintf("http://localhost:%s/", cfg.Port)
 
 	storage.RegisterClients(
-		storage.NativeClient("native", strings.Split(os.Getenv("REDIRECT_URI"), ",")...),
+		storage.NativeClient("native", cfg.RedirectURI...),
 		storage.WebClient("web", "secret"),
 		storage.WebClient("api", "secret"),
 	)
@@ -26,23 +39,20 @@ func main() {
 	// the OpenIDProvider interface needs a Storage interface handling various checks and state manipulations
 	// this might be the layer for accessing your database
 	// in this example it will be handled in-memory
-	storage := storage.NewStorage(storage.NewUserStore(issuer))
-
-	logger := slog.New(
-		slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{
-			AddSource: true,
-			Level:     slog.LevelDebug,
-		}),
-	)
+	store, err := getUserStore(cfg)
+	if err != nil {
+		logger.Error("cannot create UserStore", "error", err)
+		os.Exit(1)
+	}
+	storage := storage.NewStorage(store)
 	router := exampleop.SetupServer(issuer, storage, logger, false)
 
 	server := &http.Server{
-		Addr:    ":" + port,
+		Addr:    ":" + cfg.Port,
 		Handler: router,
 	}
-	logger.Info("server listening, press ctrl+c to stop", "addr", fmt.Sprintf("http://localhost:%s/", port))
-	err := server.ListenAndServe()
-	if err != http.ErrServerClosed {
+	logger.Info("server listening, press ctrl+c to stop", "addr", issuer)
+	if server.ListenAndServe() != http.ErrServerClosed {
 		logger.Error("server terminated", "error", err)
 		os.Exit(1)
 	}
diff --git a/example/server/storage/oidc.go b/example/server/storage/oidc.go
index 9cd08d9..22c0295 100644
--- a/example/server/storage/oidc.go
+++ b/example/server/storage/oidc.go
@@ -121,7 +121,7 @@ func (a *AuthRequest) Done() bool {
 }
 
 func PromptToInternal(oidcPrompt oidc.SpaceDelimitedArray) []string {
-	prompts := make([]string, len(oidcPrompt))
+	prompts := make([]string, 0, len(oidcPrompt))
 	for _, oidcPrompt := range oidcPrompt {
 		switch oidcPrompt {
 		case oidc.PromptNone,
diff --git a/example/server/storage/user.go b/example/server/storage/user.go
index 173daef..ed8cdfa 100644
--- a/example/server/storage/user.go
+++ b/example/server/storage/user.go
@@ -2,6 +2,8 @@ package storage
 
 import (
 	"crypto/rsa"
+	"encoding/json"
+	"os"
 	"strings"
 
 	"golang.org/x/text/language"
@@ -35,6 +37,18 @@ type userStore struct {
 	users map[string]*User
 }
 
+func StoreFromFile(path string) (UserStore, error) {
+	users := map[string]*User{}
+	data, err := os.ReadFile(path)
+	if err != nil {
+		return nil, err
+	}
+	if err := json.Unmarshal(data, &users); err != nil {
+		return nil, err
+	}
+	return userStore{users}, nil
+}
+
 func NewUserStore(issuer string) UserStore {
 	hostname := strings.Split(strings.Split(issuer, "://")[1], ":")[0]
 	return userStore{
diff --git a/example/server/storage/user_test.go b/example/server/storage/user_test.go
new file mode 100644
index 0000000..c2e2212
--- /dev/null
+++ b/example/server/storage/user_test.go
@@ -0,0 +1,70 @@
+package storage
+
+import (
+	"os"
+	"path"
+	"reflect"
+	"testing"
+
+	"golang.org/x/text/language"
+)
+
+func TestStoreFromFile(t *testing.T) {
+	for _, tc := range []struct {
+		name       string
+		pathToFile string
+		content    string
+		want       UserStore
+		wantErr    bool
+	}{
+		{
+			name:       "normal user file",
+			pathToFile: "userfile.json",
+			content: `{
+				"id1": {
+					"ID":                "id1",
+					"EmailVerified":     true,
+					"PreferredLanguage": "DE"
+				}
+			}`,
+			want: userStore{map[string]*User{
+				"id1": {
+					ID:                "id1",
+					EmailVerified:     true,
+					PreferredLanguage: language.German,
+				},
+			}},
+		},
+		{
+			name:       "malformed file",
+			pathToFile: "whatever",
+			content:    "not a json just a text",
+			wantErr:    true,
+		},
+		{
+			name:       "not existing file",
+			pathToFile: "what/ever/file",
+			wantErr:    true,
+		},
+	} {
+		t.Run(tc.name, func(t *testing.T) {
+			actualPath := path.Join(t.TempDir(), tc.pathToFile)
+
+			if tc.content != "" && tc.pathToFile != "" {
+				if err := os.WriteFile(actualPath, []byte(tc.content), 0666); err != nil {
+					t.Fatalf("cannot create file with test content: %q", tc.content)
+				}
+			}
+			result, err := StoreFromFile(actualPath)
+			if err != nil && !tc.wantErr {
+				t.Errorf("StoreFromFile(%q) returned unexpected error %q", tc.pathToFile, err)
+			} else if err == nil && tc.wantErr {
+				t.Errorf("StoreFromFile(%q) did not return an expected error", tc.pathToFile)
+			}
+			if !tc.wantErr && !reflect.DeepEqual(tc.want, result.(userStore)) {
+				t.Errorf("expected StoreFromFile(%q) = %v, but got %v",
+					tc.pathToFile, tc.want, result)
+			}
+		})
+	}
+}
diff --git a/go.mod b/go.mod
index c0b2b1a..f50972a 100644
--- a/go.mod
+++ b/go.mod
@@ -3,7 +3,7 @@ module github.com/zitadel/oidc/v3
 go 1.21
 
 require (
-	github.com/bmatcuk/doublestar/v4 v4.6.1
+	github.com/bmatcuk/doublestar/v4 v4.7.1
 	github.com/go-chi/chi/v5 v5.1.0
 	github.com/go-jose/go-jose/v4 v4.0.4
 	github.com/golang/mock v1.6.0
@@ -16,11 +16,11 @@ require (
 	github.com/rs/cors v1.11.1
 	github.com/sirupsen/logrus v1.9.3
 	github.com/stretchr/testify v1.9.0
-	github.com/zitadel/logging v0.6.0
+	github.com/zitadel/logging v0.6.1
 	github.com/zitadel/schema v1.3.0
 	go.opentelemetry.io/otel v1.29.0
 	golang.org/x/oauth2 v0.23.0
-	golang.org/x/text v0.18.0
+	golang.org/x/text v0.19.0
 )
 
 require (
diff --git a/go.sum b/go.sum
index 87d510a..91de9bf 100644
--- a/go.sum
+++ b/go.sum
@@ -1,5 +1,5 @@
-github.com/bmatcuk/doublestar/v4 v4.6.1 h1:FH9SifrbvJhnlQpztAx++wlkk70QBf0iBWDwNy7PA4I=
-github.com/bmatcuk/doublestar/v4 v4.6.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
+github.com/bmatcuk/doublestar/v4 v4.7.1 h1:fdDeAqgT47acgwd9bd9HxJRDmc9UAmPpc+2m0CXv75Q=
+github.com/bmatcuk/doublestar/v4 v4.7.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
 github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
 github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
 github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
@@ -50,8 +50,8 @@ github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/
 github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
 github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
 github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
-github.com/zitadel/logging v0.6.0 h1:t5Nnt//r+m2ZhhoTmoPX+c96pbMarqJvW1Vq6xFTank=
-github.com/zitadel/logging v0.6.0/go.mod h1:Y4CyAXHpl3Mig6JOszcV5Rqqsojj+3n7y2F591Mp/ow=
+github.com/zitadel/logging v0.6.1 h1:Vyzk1rl9Kq9RCevcpX6ujUaTYFX43aa4LkvV1TvUk+Y=
+github.com/zitadel/logging v0.6.1/go.mod h1:Y4CyAXHpl3Mig6JOszcV5Rqqsojj+3n7y2F591Mp/ow=
 github.com/zitadel/schema v1.3.0 h1:kQ9W9tvIwZICCKWcMvCEweXET1OcOyGEuFbHs4o5kg0=
 github.com/zitadel/schema v1.3.0/go.mod h1:NptN6mkBDFvERUCvZHlvWmmME+gmZ44xzwRXwhzsbtc=
 go.opentelemetry.io/otel v1.29.0 h1:PdomN/Al4q/lN6iBJEN3AwPvUiHPMlt93c8bqTG5Llw=
@@ -88,8 +88,8 @@ golang.org/x/sys v0.22.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
 golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
 golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
 golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
-golang.org/x/text v0.18.0 h1:XvMDiNzPAl0jr17s6W9lcaIhGUfUORdGCNsuLmPG224=
-golang.org/x/text v0.18.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY=
+golang.org/x/text v0.19.0 h1:kTxAhCbGbxhK0IwgSKiMO5awPoDQ0RpfiVYBfK860YM=
+golang.org/x/text v0.19.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY=
 golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
 golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
 golang.org/x/tools v0.1.1/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=