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 01d7d47..c1ff0aa 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 | @@ -115,7 +145,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=