From 73f1156d045c482004668c86bbb0940c7f25a246 Mon Sep 17 00:00:00 2001 From: "Thomas A. de Ruiter" Date: Wed, 8 Jan 2025 13:24:29 +0100 Subject: [PATCH] Support (optional) OIDC authentication (#142) * Support (optional) OIDC authentication * Various enhancements * Update README.md * Update README, add tests * Show Reauthenticate button with unauthorized error * Update README * Log error getting ExpiresAt from session * Rewrite session expire check * Rename OIDC client id and secret * Remove auth info endpoint * Rename test --- README.md | 14 ++- config.go | 25 ++++- deploy/.gitignore | 2 + deploy/config.yaml.example | 14 +++ go.mod | 14 ++- go.sum | 39 +++++-- main.go | 40 ++++++- makefile | 3 +- oidc/config.go | 60 ++++++++++ oidc/oidc.go | 163 ++++++++++++++++++++++++++++ oidc/oidc_test.go | 123 +++++++++++++++++++++ web/src/components/ErrorMessage.vue | 28 ++++- 12 files changed, 497 insertions(+), 28 deletions(-) create mode 100644 deploy/.gitignore create mode 100644 deploy/config.yaml.example create mode 100644 oidc/config.go create mode 100644 oidc/oidc.go create mode 100644 oidc/oidc_test.go diff --git a/README.md b/README.md index e2487c3..812e57b 100644 --- a/README.md +++ b/README.md @@ -3,8 +3,9 @@ Application which shows how to integrate with the Nuts node to administer identi ## Warning on authentication -This application does not support user authentication. Make sure to restrict access in any other case than local development. -The application proxies REST API calls to the configured Nuts node, so leaving it unsecured will allow anyone to access the proxied Nuts node REST APIss. +This application does support OIDC user authentication. (This has only been tested with Azure Entra ID, but it should work with any OIDC provider.) +However, if OIDC user authentication is not enabled, make sure to restrict access in any other case than local development. +The application proxies REST API calls to the configured Nuts node, so leaving it unsecured will allow anyone to access the proxied Nuts node REST APIs. ## Running Example running the application, connecting to a Nuts node running on `http://nutsnode:8081`: @@ -21,10 +22,17 @@ It supports the following configuration options: - `port` or `PORT`: overrides the default HTTP port (`1305`) the application listens on. - `node.address` or `NUTS_NODE_ADDRESS`: points to the internal API of the Nuts node, e.g. `http://nutsnode:8081`. +The following properties configure OIDC user authorization in Nuts admin: +- `oidc.enabled` or `NUTS_OIDC_ENABLED`: set to `true` to enable OIDC user authentication. +- `oidc.metadata` or `NUTS_OIDC_METADATA`: points to the OIDC metadata endpoint, e.g. `https://auth.example.com/.well-known/openid-configuration`. +- `oidc.client.id` or `NUTS_OIDC_CLIENT_ID`: the client ID to use for OIDC authentication. +- `oidc.client.secret` or `NUTS_OIDC_CLIENT_SECRET`: the client secret to use for OIDC authentication. +- `oidc.scope` or `NUTS_OIDC_SCOPE`: the scope(s) to use for OIDC authentication, defaults to `openid`, `profile`, and `email`. + The following properties should be used if API authentication is enabled on the Nuts node: - `node.auth.keyfile` or `NUTS_NODE_AUTH_KEYFILE`: points to a PEM encoded private key file. The corresponding public key should be configured on the Nuts node in SSH authorized keys format. - `node.auth.user` or `NUTS_NODE_AUTH_USER`: must match the user in the SSH authorized keys file. -- `node.auth.audience` or `NUTS_NODE_AUTH_AUDIENCE` must match the configured audience. +- `node.auth.audience` or `NUTS_NODE_AUTH_AUDIENCE`: must match the configured audience. ## Development diff --git a/config.go b/config.go index 6e73fa7..c2299ce 100644 --- a/config.go +++ b/config.go @@ -9,11 +9,13 @@ import ( "encoding/json" "errors" "fmt" - "golang.org/x/crypto/ssh" "log" "os" "strings" + "github.com/nuts-foundation/nuts-admin/oidc" + "golang.org/x/crypto/ssh" + "github.com/knadh/koanf" "github.com/knadh/koanf/parsers/yaml" "github.com/knadh/koanf/providers/env" @@ -34,14 +36,17 @@ func defaultConfig() Config { Address: "http://localhost:8081", }, AccessLogs: true, + OIDC: oidc.DefaultConfig(), } } type Config struct { - HTTPPort int `koanf:"port"` - Node Node `koanf:"node"` - AccessLogs bool `koanf:"accesslogs"` + HTTPPort int `koanf:"port"` + BaseURL string `koanf:"url"` + Node Node `koanf:"node"` + AccessLogs bool `koanf:"accesslogs"` apiKey crypto.Signer + OIDC oidc.Config `koanf:"oidc"` } type Node struct { @@ -67,6 +72,18 @@ func generateSessionKey() (*ecdsa.PrivateKey, error) { return key, nil } +func (c Config) Validate() error { + if err := c.OIDC.Validate(); err != nil { + return fmt.Errorf("oidc config error: %w", err) + } + + if c.OIDC.Enabled && c.BaseURL == "" { + return errors.New("url is required when oidc is enabled") + } + + return nil +} + func (c Config) Print() { data, _ := json.Marshal(c) logger.Info().Msgf("Config: %s", string(data)) diff --git a/deploy/.gitignore b/deploy/.gitignore new file mode 100644 index 0000000..db82a41 --- /dev/null +++ b/deploy/.gitignore @@ -0,0 +1,2 @@ +*.config.yaml +!admin.config.yaml diff --git a/deploy/config.yaml.example b/deploy/config.yaml.example new file mode 100644 index 0000000..5bb9815 --- /dev/null +++ b/deploy/config.yaml.example @@ -0,0 +1,14 @@ +port: 1305 +url: https://nuts.example.com +node: + address: http://localhost:8081 +oidc: + enabled: true + metadata: https://login.microsoftonline.com/xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx/v2.0/.well-known/openid-configuration + client: + id: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx + secret: xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx + scope: + - openid + - profile + - email diff --git a/go.mod b/go.mod index bb2d144..461693a 100644 --- a/go.mod +++ b/go.mod @@ -6,19 +6,24 @@ toolchain go1.22.4 require ( github.com/google/uuid v1.5.0 + github.com/gorilla/securecookie v1.1.1 github.com/knadh/koanf v1.5.0 github.com/labstack/echo/v4 v4.11.4 - github.com/lestrrat-go/jwx v1.2.28 + github.com/lestrrat-go/jwx v1.2.29 + github.com/markbates/goth v1.80.0 github.com/nuts-foundation/go-did v0.14.0 github.com/nuts-foundation/go-nuts-client v0.1.6 github.com/oapi-codegen/runtime v1.1.1 + github.com/quasoft/memstore v0.0.0-20191010062613-2bce066d2b0b github.com/rs/zerolog v1.33.0 github.com/spf13/pflag v1.0.5 + github.com/stretchr/testify v1.9.0 golang.org/x/crypto v0.25.0 ) require ( github.com/apapsch/go-jsonmerge/v2 v2.0.0 // indirect + github.com/davecgh/go-spew v1.1.1 // indirect github.com/decred/dcrd/dcrec/secp256k1/v4 v4.3.0 // indirect github.com/fsnotify/fsnotify v1.4.9 // indirect github.com/getkin/kin-openapi v0.124.0 // indirect @@ -26,6 +31,9 @@ require ( github.com/go-openapi/swag v0.22.8 // indirect github.com/goccy/go-json v0.10.2 // indirect github.com/golang-jwt/jwt v3.2.2+incompatible // indirect + github.com/gorilla/context v1.1.1 // indirect + github.com/gorilla/mux v1.8.1 // indirect + github.com/gorilla/sessions v1.1.1 // indirect github.com/invopop/yaml v0.2.0 // indirect github.com/josharian/intern v1.0.0 // indirect github.com/labstack/gommon v0.4.2 // indirect @@ -50,12 +58,14 @@ require ( github.com/oapi-codegen/oapi-codegen/v2 v2.3.0 // indirect github.com/perimeterx/marshmallow v1.1.5 // indirect github.com/pkg/errors v0.9.1 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect github.com/segmentio/asm v1.2.0 // indirect github.com/shengdoushi/base58 v1.0.0 // indirect github.com/valyala/bytebufferpool v1.0.0 // indirect github.com/valyala/fasttemplate v1.2.2 // indirect golang.org/x/mod v0.17.0 // indirect - golang.org/x/net v0.25.0 // indirect + golang.org/x/net v0.27.0 // indirect + golang.org/x/oauth2 v0.21.0 // indirect golang.org/x/sys v0.22.0 // indirect golang.org/x/text v0.16.0 // indirect golang.org/x/time v0.5.0 // indirect diff --git a/go.sum b/go.sum index 9d96a3d..5c862e0 100644 --- a/go.sum +++ b/go.sum @@ -107,10 +107,20 @@ github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/ github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.7/go.mod h1:n+brtR0CgQNWTVd5ZUFpTBC8YFBDLK/h/bpaJ8/DtOE= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.5.0 h1:1p67kYwdtXjb0gL0BPiP1Av9wiZPo5A8z2cWkTZ+eyU= github.com/google/uuid v1.5.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gorilla/context v1.1.1 h1:AWwleXJkX/nhcU9bZSnZoi3h/qGYqQAGhq6zZe/aQW8= +github.com/gorilla/context v1.1.1/go.mod h1:kBGZzfjB9CEq2AlWe17Uuf7NDRt0dE0s8S51q0aT7Yg= +github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY= +github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ= +github.com/gorilla/securecookie v1.1.1 h1:miw7JPhV+b/lAHSXz4qd/nN9jRiAFV5FwjeKyCS8BvQ= +github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4= +github.com/gorilla/sessions v1.1.1 h1:YMDmfaK68mUixINzY/XjscuJ47uXFWSSHzFbBQM0PrE= +github.com/gorilla/sessions v1.1.1/go.mod h1:8KCfur6+4Mqcc6S0FEfKuN15Vl5MgXW92AE8ovaJD0w= github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0/go.mod h1:8NvIoxWQoOIhqOTXgfV/d3M/q6VIi02HzZEHgUlZvzk= github.com/grpc-ecosystem/grpc-gateway v1.16.0/go.mod h1:BDjrQk3hbvj6Nolgz8mAMFbcEtjT1g+wF4CSlocrBnw= github.com/hashicorp/consul/api v1.13.0/go.mod h1:ZlVrynguJKcYr54zGaDbaL3fOvKC9m72FhPvA8T35KQ= @@ -193,8 +203,8 @@ github.com/lestrrat-go/httprc v1.0.5 h1:bsTfiH8xaKOJPrg1R+E3iE/AWZr/x0Phj9PBTG/O github.com/lestrrat-go/httprc v1.0.5/go.mod h1:mwwz3JMTPBjHUkkDv/IGJ39aALInZLrhBp0X7KGUZlo= github.com/lestrrat-go/iter v1.0.2 h1:gMXo1q4c2pHmC3dn8LzRhJfP1ceCbgSiT9lUydIzltI= github.com/lestrrat-go/iter v1.0.2/go.mod h1:Momfcq3AnRlRjI5b5O8/G5/BvpzrhoFTZcn06fEOPt4= -github.com/lestrrat-go/jwx v1.2.28 h1:uadI6o0WpOVrBSf498tRXZIwPpEtLnR9CvqPFXeI5sA= -github.com/lestrrat-go/jwx v1.2.28/go.mod h1:nF+91HEMh/MYFVwKPl5HHsBGMPscqbQb+8IDQdIazP8= +github.com/lestrrat-go/jwx v1.2.29 h1:QT0utmUJ4/12rmsVQrJ3u55bycPkKqGYuGT4tyRhxSQ= +github.com/lestrrat-go/jwx v1.2.29/go.mod h1:hU8k2l6WF0ncx20uQdOmik/Gjg6E3/wIRtXSNFeZuB8= github.com/lestrrat-go/jwx/v2 v2.0.21 h1:jAPKupy4uHgrHFEdjVjNkUgoBKtVDgrQPB/h55FHrR0= github.com/lestrrat-go/jwx/v2 v2.0.21/go.mod h1:09mLW8zto6bWL9GbwnqAli+ArLf+5M33QLQPDggkUWM= github.com/lestrrat-go/option v1.0.0/go.mod h1:5ZHFbivi4xwXxhxY9XHDe2FHo6/Z7WWmtT7T5nBBp3I= @@ -202,6 +212,8 @@ github.com/lestrrat-go/option v1.0.1 h1:oAzP2fvZGQKWkvHa1/SAcFolBEca1oN+mQ7eooNB github.com/lestrrat-go/option v1.0.1/go.mod h1:5ZHFbivi4xwXxhxY9XHDe2FHo6/Z7WWmtT7T5nBBp3I= github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= +github.com/markbates/goth v1.80.0 h1:NnvatczZDzOs1hn9Ug+dVYf2Viwwkp/ZDX5K+GLjan8= +github.com/markbates/goth v1.80.0/go.mod h1:4/GYHo+W6NWisrMPZnq0Yr2Q70UntNLn7KXEFhrIdAY= github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU= github.com/mattn/go-colorable v0.1.4/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE= github.com/mattn/go-colorable v0.1.6/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= @@ -254,10 +266,6 @@ github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f/go.mod h1:qRW github.com/npillmayer/nestext v0.1.3/go.mod h1:h2lrijH8jpicr25dFY+oAJLyzlya6jhnuG+zWp9L0Uk= github.com/nuts-foundation/go-did v0.14.0 h1:Y1tuQCC2xmDX1bdXQS9iquwzJgcT1zcJxbZkqC5Dfac= github.com/nuts-foundation/go-did v0.14.0/go.mod h1:dQm9b2dYUnhgVW1FmpAi5nNe0mfIrnxM3EaQx4GsDhI= -github.com/nuts-foundation/go-nuts-client v0.1.3 h1:greij2A5o12T3CZisszkDksfokH3IasaiXyU1E9hz6A= -github.com/nuts-foundation/go-nuts-client v0.1.3/go.mod h1:/apCT1jOnplzvbAsP0GvTgMyUA0fPtesUDwi7VGEi5U= -github.com/nuts-foundation/go-nuts-client v0.1.4 h1:UV010QfqMO5GFCHbWBxzMCSxrrK1Rw8f3a1NTsU85yQ= -github.com/nuts-foundation/go-nuts-client v0.1.4/go.mod h1:/apCT1jOnplzvbAsP0GvTgMyUA0fPtesUDwi7VGEi5U= github.com/nuts-foundation/go-nuts-client v0.1.6 h1:wQcIcu8+1+zU2fp+B6Gds0rccqBOw5gCc0EBPKotIp0= github.com/nuts-foundation/go-nuts-client v0.1.6/go.mod h1:/apCT1jOnplzvbAsP0GvTgMyUA0fPtesUDwi7VGEi5U= github.com/oapi-codegen/oapi-codegen/v2 v2.3.0 h1:rICjNsHbPP1LttefanBPnwsSwl09SqhCO7Ee623qR84= @@ -295,6 +303,8 @@ github.com/prometheus/procfs v0.0.0-20181005140218-185b4288413d/go.mod h1:c3At6R github.com/prometheus/procfs v0.0.2/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA= github.com/prometheus/procfs v0.1.3/go.mod h1:lV6e/gmhEcM9IjHGsFOCxxuZ+z1YqCvr4OA4YeYWdaU= github.com/prometheus/procfs v0.6.0/go.mod h1:cz+aTbrPOrUb4q7XlbU9ygM+/jj0fzG6c1xBZuNvfVA= +github.com/quasoft/memstore v0.0.0-20191010062613-2bce066d2b0b h1:aUNXCGgukb4gtY99imuIeoh8Vr0GSwAlYxPAhqZrpFc= +github.com/quasoft/memstore v0.0.0-20191010062613-2bce066d2b0b/go.mod h1:wTPjTepVu7uJBYgZ0SdWHQlIas582j6cn2jgk4DDdlg= github.com/rhnvrm/simples3 v0.6.1/go.mod h1:Y+3vYm2V7Y4VijFoJHHTrja6OgPrJ2cBti8dPGkC3sA= github.com/rogpeppe/fastuuid v1.2.0/go.mod h1:jVj6XXZzXRy/MSR5jhDC/2q6DgLz+nrA6LYCDYWNEvQ= github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8= @@ -320,6 +330,7 @@ github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+ github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= @@ -353,7 +364,8 @@ golang.org/x/crypto v0.0.0-20190923035154-9ee001bba392/go.mod h1:/lpIB1dKB+9EgE3 golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= -golang.org/x/crypto v0.17.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4= +golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= +golang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOMs= golang.org/x/crypto v0.25.0 h1:ypSNr+bnYL2YhwoMt2zPxHFmbAN1KZs/njMG3hxUp30= golang.org/x/crypto v0.25.0/go.mod h1:T+wALwcMOSE0kXgUAnPAHqTLW+XHgcELELW8VaDgm/M= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= @@ -389,11 +401,14 @@ golang.org/x/net v0.0.0-20210410081132-afb366fc7cd1/go.mod h1:9tjilg8BloeKEkVJvy golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= -golang.org/x/net v0.25.0 h1:d/OCCoBEUq33pjydKrGQhw7IlUPI2Oylr+8qLx49kac= -golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= +golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= +golang.org/x/net v0.27.0 h1:5K3Njcw06/l2y9vpGCSdcxWOYHOUk3dVNGDXN+FvAys= +golang.org/x/net v0.27.0/go.mod h1:dDi0PyhWNoiUOrAS8uXv/vnScO4wnHQO4mj9fn/RytE= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= +golang.org/x/oauth2 v0.21.0 h1:tsimM75w1tF/uws5rbeHzIWxEqElMehnc+iW793zsZs= +golang.org/x/oauth2 v0.21.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -445,14 +460,16 @@ golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.22.0 h1:RI27ohtqKCnwULzJLqkv897zojh5/DwS/ENaMzUOaWI= 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/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= -golang.org/x/term v0.15.0/go.mod h1:BDl952bC7+uMoWR75FIrCDx79TPU9oHkTZ9yRbYOrX0= +golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= +golang.org/x/term v0.18.0/go.mod h1:ILwASektA3OnRv7amZ1xhE/KTR+u50pbXfZ03+6Nx58= golang.org/x/term v0.22.0 h1:BbsgPEJULsl2fV/AT3v15Mjva5yXKQDyKf+TbDz7QJk= golang.org/x/term v0.22.0/go.mod h1:F3qCibpT5AMpCRfhfT53vVJwhLtIVHhB9XDjfFvnMI4= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= diff --git a/main.go b/main.go index a7970fa..c0f08f6 100644 --- a/main.go +++ b/main.go @@ -7,6 +7,14 @@ import ( "embed" "errors" "fmt" + "io/fs" + "log" + "net/http" + "net/url" + "os" + "strings" + "time" + "github.com/google/uuid" "github.com/labstack/echo/v4/middleware" "github.com/lestrrat-go/jwx/jwk" @@ -17,13 +25,8 @@ import ( "github.com/nuts-foundation/nuts-admin/discovery" "github.com/nuts-foundation/nuts-admin/identity" "github.com/nuts-foundation/nuts-admin/issuer" + "github.com/nuts-foundation/nuts-admin/oidc" "github.com/rs/zerolog" - "io/fs" - "log" - "net/http" - "net/url" - "os" - "time" "github.com/labstack/echo/v4" "github.com/lestrrat-go/jwx/jwa" @@ -51,10 +54,24 @@ func getFileSystem(useFS bool) http.FileSystem { return http.FS(fsys) } +func authSkipper(c echo.Context) bool { + // For the following, skip authorization + return strings.HasPrefix(c.Request().URL.Path, "/status") +} + +func redirectSkipper(c echo.Context) bool { + // For the following, have the API return 401 instead of redirecting to the login page + return strings.HasPrefix(c.Request().URL.Path, "/api/") +} + func main() { logger = zerolog.New(log.Writer()).With().Timestamp().Logger() config := loadConfig() config.Print() + if err := config.Validate(); err != nil { + logger.Fatal().Err(err).Msg("invalid config") + os.Exit(1) + } e := echo.New() e.HideBanner = true @@ -72,6 +89,16 @@ func main() { log.Fatalf("unable to parse node address: %s", err) } + if config.OIDC.Enabled { + err := oidc.Setup(config.OIDC, config.BaseURL, e, oidc.AuthConfig{ + Skipper: authSkipper, + RedirectSkipper: redirectSkipper, + }) + if err != nil { + log.Fatalf("unable to initialize oidc: %s", err) + } + } + // API security // TODO //tokenGenerator := func() (string, error) { @@ -113,6 +140,7 @@ func main() { e.GET("/status", func(context echo.Context) error { return context.String(http.StatusOK, "OK") }) + e.GET("/*", echo.WrapHandler(assetHandler)) // Start server diff --git a/makefile b/makefile index 7d18771..bf2c7c7 100644 --- a/makefile +++ b/makefile @@ -1,5 +1,6 @@ .PHONY: dev +CONFIG_FILE ?= deploy/admin.config.yaml run-generators: gen-api install-tools: @@ -23,7 +24,7 @@ run-nuts-node: docker compose up --wait run-api: - go run . live --configfile=deploy/admin.config.yaml + go run . live --configfile=$(CONFIG_FILE) docker: docker build -t nutsfoundation/nuts-admin:main . diff --git a/oidc/config.go b/oidc/config.go new file mode 100644 index 0000000..654ddf9 --- /dev/null +++ b/oidc/config.go @@ -0,0 +1,60 @@ +package oidc + +import ( + "errors" + "strings" +) + +type Config struct { + Enabled bool `koanf:"enabled"` + MetadataURL string `koanf:"metadata"` + Client ClientConfig `koanf:"client"` + Scope []string `koanf:"scope"` +} + +type ClientConfig struct { + ID string `koanf:"id"` + Secret string `koanf:"secret"` +} + +func DefaultConfig() Config { + return Config{ + Enabled: false, + Scope: []string{ + "openid", + "profile", + "email", + }, + } +} + +func (c Config) Validate() error { + if !c.Enabled { + return nil + } + + if c.MetadataURL == "" { + return errors.New("metadata_url is required") + } + + if c.Client.ID == "" { + return errors.New("client_id is required") + } + + if c.Client.Secret == "" { + return errors.New("client_secret is required") + } + + scopeCount := 0 + for _, scope := range c.Scope { + if strings.TrimSpace(scope) == "" { + return errors.New("scope cannot be an empty string") + } + scopeCount++ + } + if scopeCount == 0 { + return errors.New("at lease once scope is required") + } + + return nil +} diff --git a/oidc/oidc.go b/oidc/oidc.go new file mode 100644 index 0000000..5d518cd --- /dev/null +++ b/oidc/oidc.go @@ -0,0 +1,163 @@ +package oidc + +import ( + "errors" + "fmt" + "net/http" + "strconv" + "strings" + "time" + + "github.com/gorilla/securecookie" + "github.com/labstack/echo/v4" + "github.com/labstack/echo/v4/middleware" + "github.com/markbates/goth" + "github.com/markbates/goth/gothic" + "github.com/markbates/goth/providers/openidConnect" + "github.com/quasoft/memstore" +) + +type OIDC struct { + baseURL string + signInUrl string + callbackURL string +} + +func Setup(config Config, baseURL string, e *echo.Echo, authConfig AuthConfig) error { + const name = "openid-connect" + + o := &OIDC{ + baseURL: baseURL, + } + + if o.baseURL == "" { + o.baseURL = "/" + } + o.signInUrl = fmt.Sprintf("%s/auth/%s", o.baseURL, name) + o.callbackURL = fmt.Sprintf("%s/auth/%s/callback", o.baseURL, name) + + authConfig.redirectURL = o.signInUrl + + // Set up a Session Store for Goth + sessionStore := memstore.NewMemStore( + securecookie.GenerateRandomKey(32), + securecookie.GenerateRandomKey(32), + ) + gothic.Store = sessionStore + + // Setup OIDC provider for Goth + provider, err := openidConnect.New( + config.Client.ID, + config.Client.Secret, + o.callbackURL, + config.MetadataURL, + config.Scope..., + ) + if err != nil { + return err + } + if provider == nil { + return errors.New("oidc provider failed to initialize") + } + goth.UseProviders(provider) + + o.RegisterHandlers(e) + + e.Use(AuthWithConfig(authConfig)) + + return nil +} + +func (o *OIDC) RegisterHandlers(e *echo.Echo) { + e.GET("/auth/:provider", func(c echo.Context) error { + provider := c.Param("provider") + if provider == "" { + return c.String(http.StatusBadRequest, "Provider not specified") + } + + q := c.Request().URL.Query() + q.Add("provider", c.Param("provider")) + c.Request().URL.RawQuery = q.Encode() + + req := c.Request() + res := c.Response().Writer + if gothUser, err := gothic.CompleteUserAuth(res, req); err == nil { + return c.JSON(http.StatusOK, gothUser) + } + gothic.BeginAuthHandler(res, req) + return nil + }) + + e.GET("/auth/:provider/callback", func(c echo.Context) error { + req := c.Request() + res := c.Response().Writer + user, err := gothic.CompleteUserAuth(res, req) + if err != nil { + return c.String(http.StatusBadRequest, err.Error()) + } + + err = gothic.StoreInSession("ExpiresAt", strconv.FormatInt(user.ExpiresAt.Unix(), 10), req, res) + if err != nil { + return c.String(http.StatusBadRequest, err.Error()) + } + + return c.Redirect(http.StatusTemporaryRedirect, o.baseURL) + }) +} + +type AuthConfig struct { + Skipper middleware.Skipper + RedirectSkipper middleware.Skipper + redirectURL string +} + +var DefaultAuthConfig = AuthConfig{ + Skipper: middleware.DefaultSkipper, + RedirectSkipper: middleware.DefaultSkipper, +} + +func Auth() echo.MiddlewareFunc { + c := DefaultAuthConfig + return AuthWithConfig(c) +} + +func AuthWithConfig(config AuthConfig) echo.MiddlewareFunc { + return func(next echo.HandlerFunc) echo.HandlerFunc { + return func(c echo.Context) error { + req := c.Request() + + if config.Skipper(c) { + return next(c) // Skip authentication + } + + if strings.HasPrefix(req.URL.Path, "/auth/") { + return next(c) // Skip authentication + } + + validSession := false + + // Check if the ExpiresAt in the session is not expired + expiresAt, _ := gothic.GetFromSession("ExpiresAt", req) + if len(expiresAt) > 0 { + expiresAtInt, err := strconv.ParseInt(expiresAt, 10, 64) + if err == nil { + if expiresAtInt > time.Now().Unix() { + validSession = true + } + } + } + + // If authorization failed, redirect to login or return 401 + if !validSession { + if len(config.redirectURL) > 0 && !config.RedirectSkipper(c) { + return c.Redirect(http.StatusSeeOther, config.redirectURL) + } + + return echo.ErrUnauthorized + } + + // Authorized, continue + return next(c) + } + } +} diff --git a/oidc/oidc_test.go b/oidc/oidc_test.go new file mode 100644 index 0000000..f9eded1 --- /dev/null +++ b/oidc/oidc_test.go @@ -0,0 +1,123 @@ +package oidc + +import ( + "errors" + "net/http" + "net/http/httptest" + "strconv" + "strings" + "testing" + "time" + + "github.com/labstack/echo/v4" + "github.com/markbates/goth/gothic" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func fakeSignIn(expireInSeconds int64) (string, error) { + req := httptest.NewRequest(echo.GET, "/", nil) + rec := httptest.NewRecorder() + + err := gothic.StoreInSession("ExpiresAt", strconv.FormatInt(time.Now().Unix()+expireInSeconds, 10), req, rec) + if err != nil { + return "", err + } + + cookie := rec.Header().Get("Set-Cookie") + cookieParts := strings.Split(cookie, ";") + if len(cookieParts) == 0 { + return "", errors.New("failed to split set-cookie header fields") + } + + sessionParts := strings.Split(cookieParts[0], "=") + if len(sessionParts) < 2 || sessionParts[0] != "_gothic_session" { + return "", errors.New("failed to retrieve session cookie") + } + + return cookieParts[0], nil +} + +func request(e *echo.Echo, method, target, cookie string) *httptest.ResponseRecorder { + req := httptest.NewRequest(method, target, nil) + if cookie != "" { + req.Header.Set("Cookie", cookie) + } + rec := httptest.NewRecorder() + e.Server.Handler.ServeHTTP(rec, req) + return rec +} + +func TestOIDC(t *testing.T) { + // build test echo app + e := echo.New() + e.HideBanner = true + + e.GET("/protected", func(c echo.Context) error { + return c.String(200, "ok") + }) + + e.GET("/unprotected", func(c echo.Context) error { + return c.String(200, "ok") + }) + + e.GET("/api/protected", func(c echo.Context) error { + return c.String(200, "ok") + }) + + // setup OIDC + config := Config{ + Enabled: true, + Client: ClientConfig{ + ID: "client_id", + Secret: "client_secret", + }, + //FIXME: Find a better way to load an openid-configuration + MetadataURL: "https://accounts.google.com/.well-known/openid-configuration", + Scope: []string{"openid", "profile", "email"}, + } + + baseURL := "http://localhost:8080" + authConfig := AuthConfig{ + Skipper: func(c echo.Context) bool { + // for the following, skip authorization + return strings.HasPrefix(c.Request().URL.Path, "/unprotected") + }, + RedirectSkipper: func(c echo.Context) bool { + // for the following, don't redirect but return 401 + return strings.HasPrefix(c.Request().URL.Path, "/api/") + }, + } + + err := Setup(config, baseURL, e, authConfig) + require.NoError(t, err) + + t.Run("unauthorized to unprotected route gives 200 OK", func(t *testing.T) { + rec := request(e, echo.GET, "/unprotected", "") + assert.Equal(t, http.StatusOK, rec.Code) + }) + + t.Run("unauthorized to protected route gives 303 redirect to login", func(t *testing.T) { + rec := request(e, echo.GET, "/protected", "") + assert.Equal(t, http.StatusSeeOther, rec.Code) + }) + + t.Run("unauthorized to protected api gives 401 Unauthorized", func(t *testing.T) { + rec := request(e, echo.GET, "/api/protected", "") + assert.Equal(t, http.StatusUnauthorized, rec.Code) + }) + + t.Run("authorized to protected api gives 200 OK", func(t *testing.T) { + cookie, err := fakeSignIn(60) + assert.NoError(t, err) + rec := request(e, echo.GET, "/api/protected", cookie) + assert.Equal(t, http.StatusOK, rec.Code) + }) + + t.Run("expired session to protected api gives 401 Unauthorized", func(t *testing.T) { + cookie, err := fakeSignIn(-60) + assert.NoError(t, err) + rec := request(e, echo.GET, "/api/protected", cookie) + assert.Equal(t, http.StatusUnauthorized, rec.Code) + }) +} diff --git a/web/src/components/ErrorMessage.vue b/web/src/components/ErrorMessage.vue index 8de31f1..258133c 100644 --- a/web/src/components/ErrorMessage.vue +++ b/web/src/components/ErrorMessage.vue @@ -3,7 +3,15 @@ class="appearance-none mb-2 mt-2 px-6 py-3.5 w-full text-sm text-red-500 bg-red-100 placeholder-red-500 outline-none border border-red-500 focus:ring-4 focus:ring-blue-200 rounded-md" role="alert">
{{ title}}
- {{ message }} +
{{ message }}
+
+ +