diff --git a/go.mod b/go.mod index f9814f1d6..bfafeb500 100644 --- a/go.mod +++ b/go.mod @@ -5,7 +5,7 @@ go 1.20 require ( github.com/cenkalti/backoff/v4 v4.1.3 github.com/golang-jwt/jwt v3.2.2+incompatible - github.com/golang/protobuf v1.5.2 + github.com/golang/protobuf v1.5.3 github.com/google/uuid v1.3.0 github.com/gorilla/mux v1.8.0 github.com/kardianos/service v1.2.1-0.20210728001519-a323c3813bc7 @@ -18,12 +18,12 @@ require ( github.com/spf13/pflag v1.0.5 github.com/vishvananda/netlink v1.1.0 golang.org/x/crypto v0.7.0 - golang.org/x/sys v0.6.0 + golang.org/x/sys v0.8.0 golang.zx2c4.com/wireguard v0.0.0-20230223181233-21636207a675 golang.zx2c4.com/wireguard/wgctrl v0.0.0-20211215182854-7a385b3431de golang.zx2c4.com/wireguard/windows v0.5.3 google.golang.org/grpc v1.52.3 - google.golang.org/protobuf v1.28.1 + google.golang.org/protobuf v1.30.0 gopkg.in/natefinch/lumberjack.v2 v2.0.0 ) @@ -60,10 +60,11 @@ require ( go.opentelemetry.io/otel/exporters/prometheus v0.33.0 go.opentelemetry.io/otel/metric v0.33.0 go.opentelemetry.io/otel/sdk/metric v0.33.0 + goauthentik.io/api/v3 v3.2023051.3 golang.org/x/exp v0.0.0-20220518171630-0b5c67f07fdf - golang.org/x/net v0.8.0 + golang.org/x/net v0.10.0 golang.org/x/sync v0.1.0 - golang.org/x/term v0.6.0 + golang.org/x/term v0.8.0 gopkg.in/yaml.v3 v3.0.1 ) @@ -128,9 +129,11 @@ require ( go.opentelemetry.io/otel/trace v1.11.1 // indirect golang.org/x/image v0.5.0 // indirect golang.org/x/mod v0.8.0 // indirect - golang.org/x/text v0.8.0 // indirect + golang.org/x/oauth2 v0.8.0 // indirect + golang.org/x/text v0.9.0 // indirect golang.org/x/tools v0.6.0 // indirect golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect + google.golang.org/appengine v1.6.7 // indirect google.golang.org/genproto v0.0.0-20221118155620-16455021b5e6 // indirect gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 // indirect gopkg.in/tomb.v2 v2.0.0-20161208151619-d5d1b5820637 // indirect diff --git a/go.sum b/go.sum index 0ebe2a83b..f18f9c544 100644 --- a/go.sum +++ b/go.sum @@ -20,6 +20,7 @@ cloud.google.com/go/bigquery v1.4.0/go.mod h1:S8dzgnTigyfTmLBfrtrhyYhwRxG72rYxvf cloud.google.com/go/bigquery v1.5.0/go.mod h1:snEHRnqQbz117VIFhE8bmtwIDY80NLUZUMb4Nv6dBIg= cloud.google.com/go/bigquery v1.7.0/go.mod h1://okPTzCYNXSlb24MZs83e2Do+h+VXtc4gLoIoXIAPc= cloud.google.com/go/bigquery v1.8.0/go.mod h1:J5hqkt3O0uAFnINi6JXValWIb1v0goeZM77hZzJN/fQ= +cloud.google.com/go/compute/metadata v0.2.0/go.mod h1:zFmK7XCadkQkj6TtorcaGlCW1hT1fIilQDwofLpJ20k= cloud.google.com/go/datastore v1.0.0/go.mod h1:LXYbyblFSglQ5pkeyhO+Qmw7ukd3C+pD7TKLgZqpHYE= cloud.google.com/go/datastore v1.1.0/go.mod h1:umbIZjpQpHh4hmRpGhH4tLFup+FVzqBi1b3c64qFpCk= cloud.google.com/go/pubsub v1.0.1/go.mod h1:R0Gpsv3s54REJCy4fxDixWD93lHJMoZTyQ2kNxGRt3I= @@ -294,8 +295,9 @@ github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QD github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= -github.com/golang/protobuf v1.5.2 h1:ROPKBNFfQgOUMifHyP+KYbvpjbdoFNs+aK7DXlji0Tw= github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= +github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg= +github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= github.com/golang/snappy v0.0.4 h1:yAGX7huGHXlcLOEtBnF4w7FQwA26wojNCwOYAEhLjQM= github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= @@ -314,6 +316,7 @@ github.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/ github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 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.8/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/gofuzz v0.0.0-20161122191042-44d81051d367/go.mod h1:HP5RmnzzSNb993RKQDq4+1A4ia9nllfqcQFTQJedwGI= @@ -707,6 +710,8 @@ go.opentelemetry.io/proto/otlp v0.7.0/go.mod h1:PqfVotwruBrMGOCsRd/89rSnXhoiJIqe go.uber.org/atomic v1.4.0/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE= go.uber.org/multierr v1.1.0/go.mod h1:wR5kodmAFQ0UK8QlbwjlSNy0Z68gJhDJUG5sjR94q/0= go.uber.org/zap v1.10.0/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q= +goauthentik.io/api/v3 v3.2023051.3 h1:NebAhD/TeTWNo/9X3/Uj+rM5fG1HaiLOlKTNLQv9Qq4= +goauthentik.io/api/v3 v3.2023051.3/go.mod h1:nYECml4jGbp/541hj8GcylKQG1gVBsKppHy4+7G8u4U= golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= golang.org/x/crypto v0.0.0-20190211182817-74369b46fc67/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= @@ -827,8 +832,8 @@ golang.org/x/net v0.1.0/go.mod h1:Cx3nUiGt4eDBEyega/BKRp+/AlGL8hYe7U9odMt2Cco= golang.org/x/net v0.5.0/go.mod h1:DivGGAXEgPSlEBzxGzZI+ZLohi+xUj054jfeKui00ws= golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= -golang.org/x/net v0.8.0 h1:Zrh2ngAOFYneWTAIAPethzeaQLuHwhuBkuV6ZiRnUaQ= -golang.org/x/net v0.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc= +golang.org/x/net v0.10.0 h1:X2//UzNDwYmtCLn7To6G58Wr6f5ahEAQgKNzv9Y951M= +golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= 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-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= @@ -837,6 +842,8 @@ golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d/go.mod h1:gOpvHmFTYa4Iltr golang.org/x/oauth2 v0.0.0-20210514164344-f6687ab2804c/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= golang.org/x/oauth2 v0.0.0-20211005180243-6b3c2da341f1/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= golang.org/x/oauth2 v0.0.0-20220223155221-ee480838109b/go.mod h1:DAh4E804XQdzx2j+YRIaUnCqCV2RuMz24cGBJ5QYIrc= +golang.org/x/oauth2 v0.8.0 h1:6dkIjl3j3LtZ/O3sTgZTMsLKSftL/B8Zgq4huOIIUu8= +golang.org/x/oauth2 v0.8.0/go.mod h1:yr7u4HXZRm1R1kBWqr/xKNqewf0plRYoB7sla+BCIXE= 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= @@ -948,15 +955,15 @@ golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.4.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.1-0.20230222185716-a3b23cc77e89/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.6.0 h1:MVltZSvRTcU2ljQOhs94SXPftV6DCNnZViHeQps87pQ= -golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.8.0 h1:EBmGv8NaZBZTWvrbjNoL6HVt+IVy3QDQpJs7VRIw3tU= +golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 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.1.0/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.4.0/go.mod h1:9P2UbLfCdcvo3p/nzKvsmas4TnlujnuoV9hGgYzW1lQ= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= -golang.org/x/term v0.6.0 h1:clScbb1cHjoCkyRbWwBEUZ5H/tIFu5TAXIqaZD0Gcjw= -golang.org/x/term v0.6.0/go.mod h1:m6U89DPEgQRMq3DNkDClhWw02AUbt2daBVO4cn4Hv9U= +golang.org/x/term v0.8.0 h1:n5xxQn2i3PC0yLAbjTpNT85q/Kgzcr2gIoX9OrJUols= +golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= golang.org/x/text v0.0.0-20160726164857-2910a502d2bf/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= @@ -969,8 +976,8 @@ golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.6.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= -golang.org/x/text v0.8.0 h1:57P1ETyNKtuIjB4SRd15iJxuhj8Gc416Y78H3qgMh68= -golang.org/x/text v0.8.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= +golang.org/x/text v0.9.0 h1:2sjJmO8cDvYveuX97RDLsxlyUxLl+GHoLxBiRdHllBE= +golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= @@ -1062,6 +1069,7 @@ google.golang.org/appengine v1.5.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7 google.golang.org/appengine v1.6.1/go.mod h1:i06prIuMbXzDqacNJfV5OdTW448YApPu5ww/cMBSeb0= google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= google.golang.org/appengine v1.6.6/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= +google.golang.org/appengine v1.6.7 h1:FZR1q0exgwxzPzp/aF+VccGrSfxfPpkBqjIIEq3ru6c= google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= google.golang.org/genproto v0.0.0-20190307195333-5fe7a883aa19/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= @@ -1132,8 +1140,9 @@ google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlba google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= google.golang.org/protobuf v1.27.1/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= -google.golang.org/protobuf v1.28.1 h1:d0NfwRgPtno5B1Wa6L2DAG+KivqkdutMf1UhdNx175w= -google.golang.org/protobuf v1.28.1/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= +google.golang.org/protobuf v1.28.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= +google.golang.org/protobuf v1.30.0 h1:kPPoIgf3TsEvrm0PFe15JQ+570QVxYzEvvHqChK+cng= +google.golang.org/protobuf v1.30.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/management/server/idp/authentik.go b/management/server/idp/authentik.go new file mode 100644 index 000000000..5de3a9666 --- /dev/null +++ b/management/server/idp/authentik.go @@ -0,0 +1,516 @@ +package idp + +import ( + "context" + "fmt" + "github.com/golang-jwt/jwt" + "github.com/netbirdio/netbird/management/server/telemetry" + log "github.com/sirupsen/logrus" + "goauthentik.io/api/v3" + "io" + "net/http" + "net/url" + "strconv" + "strings" + "sync" + "time" +) + +// AuthentikManager authentik manager client instance. +type AuthentikManager struct { + apiClient *api.APIClient + httpClient ManagerHTTPClient + credentials ManagerCredentials + helper ManagerHelper + appMetrics telemetry.AppMetrics +} + +// AuthentikClientConfig authentik manager client configurations. +type AuthentikClientConfig struct { + Issuer string + ClientID string + Username string + Password string + TokenEndpoint string + GrantType string +} + +// AuthentikCredentials authentik authentication information. +type AuthentikCredentials struct { + clientConfig AuthentikClientConfig + helper ManagerHelper + httpClient ManagerHTTPClient + jwtToken JWTToken + mux sync.Mutex + appMetrics telemetry.AppMetrics +} + +// NewAuthentikManager creates a new instance of the AuthentikManager. +func NewAuthentikManager(config AuthentikClientConfig, + appMetrics telemetry.AppMetrics) (*AuthentikManager, error) { + httpTransport := http.DefaultTransport.(*http.Transport).Clone() + httpTransport.MaxIdleConns = 5 + + httpClient := &http.Client{ + Timeout: 10 * time.Second, + Transport: httpTransport, + } + + helper := JsonParser{} + + if config.ClientID == "" { + return nil, fmt.Errorf("authentik IdP configuration is incomplete, clientID is missing") + } + + if config.Username == "" { + return nil, fmt.Errorf("authentik IdP configuration is incomplete, Username is missing") + } + + if config.Password == "" { + return nil, fmt.Errorf("authentik IdP configuration is incomplete, Password is missing") + } + + if config.TokenEndpoint == "" { + return nil, fmt.Errorf("authentik IdP configuration is incomplete, TokenEndpoint is missing") + } + + if config.GrantType == "" { + return nil, fmt.Errorf("authentik IdP configuration is incomplete, GrantType is missing") + } + + // authentik client configuration + issuerURL, err := url.Parse(config.Issuer) + if err != nil { + return nil, err + } + authentikConfig := api.NewConfiguration() + authentikConfig.HTTPClient = httpClient + authentikConfig.Host = issuerURL.Host + authentikConfig.Scheme = issuerURL.Scheme + + credentials := &AuthentikCredentials{ + clientConfig: config, + httpClient: httpClient, + helper: helper, + appMetrics: appMetrics, + } + + return &AuthentikManager{ + apiClient: api.NewAPIClient(authentikConfig), + httpClient: httpClient, + credentials: credentials, + helper: helper, + appMetrics: appMetrics, + }, nil +} + +// jwtStillValid returns true if the token still valid and have enough time to be used and get a response from authentik. +func (ac *AuthentikCredentials) jwtStillValid() bool { + return !ac.jwtToken.expiresInTime.IsZero() && time.Now().Add(5*time.Second).Before(ac.jwtToken.expiresInTime) +} + +// requestJWTToken performs request to get jwt token. +func (ac *AuthentikCredentials) requestJWTToken() (*http.Response, error) { + data := url.Values{} + data.Set("client_id", ac.clientConfig.ClientID) + data.Set("username", ac.clientConfig.Username) + data.Set("password", ac.clientConfig.Password) + data.Set("grant_type", ac.clientConfig.GrantType) + data.Set("scope", "goauthentik.io/api") + + payload := strings.NewReader(data.Encode()) + req, err := http.NewRequest(http.MethodPost, ac.clientConfig.TokenEndpoint, payload) + if err != nil { + return nil, err + } + req.Header.Add("content-type", "application/x-www-form-urlencoded") + + log.Debug("requesting new jwt token for authentik idp manager") + + resp, err := ac.httpClient.Do(req) + if err != nil { + if ac.appMetrics != nil { + ac.appMetrics.IDPMetrics().CountRequestError() + } + + return nil, err + } + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("unable to get authentik token, statusCode %d", resp.StatusCode) + } + + return resp, nil +} + +// parseRequestJWTResponse parses jwt raw response body and extracts token and expires in seconds +func (ac *AuthentikCredentials) parseRequestJWTResponse(rawBody io.ReadCloser) (JWTToken, error) { + jwtToken := JWTToken{} + body, err := io.ReadAll(rawBody) + if err != nil { + return jwtToken, err + } + + err = ac.helper.Unmarshal(body, &jwtToken) + if err != nil { + return jwtToken, err + } + + if jwtToken.ExpiresIn == 0 && jwtToken.AccessToken == "" { + return jwtToken, fmt.Errorf("error while reading response body, expires_in: %d and access_token: %s", jwtToken.ExpiresIn, jwtToken.AccessToken) + } + + data, err := jwt.DecodeSegment(strings.Split(jwtToken.AccessToken, ".")[1]) + if err != nil { + return jwtToken, err + } + + // Exp maps into exp from jwt token + var IssuedAt struct{ Exp int64 } + err = ac.helper.Unmarshal(data, &IssuedAt) + if err != nil { + return jwtToken, err + } + jwtToken.expiresInTime = time.Unix(IssuedAt.Exp, 0) + + return jwtToken, nil +} + +// Authenticate retrieves access token to use the authentik management API. +func (ac *AuthentikCredentials) Authenticate() (JWTToken, error) { + ac.mux.Lock() + defer ac.mux.Unlock() + + if ac.appMetrics != nil { + ac.appMetrics.IDPMetrics().CountAuthenticate() + } + + // reuse the token without requesting a new one if it is not expired, + // and if expiry time is sufficient time available to make a request. + if ac.jwtStillValid() { + return ac.jwtToken, nil + } + + resp, err := ac.requestJWTToken() + if err != nil { + return ac.jwtToken, err + } + defer resp.Body.Close() + + jwtToken, err := ac.parseRequestJWTResponse(resp.Body) + if err != nil { + return ac.jwtToken, err + } + + ac.jwtToken = jwtToken + + return ac.jwtToken, nil +} + +// UpdateUserAppMetadata updates user app metadata based on userID and metadata map. +func (am *AuthentikManager) UpdateUserAppMetadata(userID string, appMetadata AppMetadata) error { + ctx, err := am.authenticationContext() + if err != nil { + return err + } + + userPk, err := strconv.ParseInt(userID, 10, 32) + if err != nil { + return err + } + + var pendingInvite bool + if appMetadata.WTPendingInvite != nil { + pendingInvite = *appMetadata.WTPendingInvite + } + + patchedUserReq := api.PatchedUserRequest{ + Attributes: map[string]interface{}{ + wtAccountID: appMetadata.WTAccountID, + wtPendingInvite: pendingInvite, + }, + } + _, resp, err := am.apiClient.CoreApi.CoreUsersPartialUpdate(ctx, int32(userPk)). + PatchedUserRequest(patchedUserReq). + Execute() + if err != nil { + return err + } + + if am.appMetrics != nil { + am.appMetrics.IDPMetrics().CountUpdateUserAppMetadata() + } + + if resp.StatusCode != http.StatusOK { + if am.appMetrics != nil { + am.appMetrics.IDPMetrics().CountRequestStatusError() + } + return fmt.Errorf("unable to update user %s, statusCode %d", userID, resp.StatusCode) + } + + return nil +} + +// GetUserDataByID requests user data from authentik via ID. +func (am *AuthentikManager) GetUserDataByID(userID string, appMetadata AppMetadata) (*UserData, error) { + ctx, err := am.authenticationContext() + if err != nil { + return nil, err + } + + userPk, err := strconv.ParseInt(userID, 10, 32) + if err != nil { + return nil, err + } + + user, resp, err := am.apiClient.CoreApi.CoreUsersRetrieve(ctx, int32(userPk)).Execute() + if err != nil { + return nil, err + } + + if am.appMetrics != nil { + am.appMetrics.IDPMetrics().CountGetUserDataByID() + } + + if resp.StatusCode != http.StatusOK { + if am.appMetrics != nil { + am.appMetrics.IDPMetrics().CountRequestStatusError() + } + return nil, fmt.Errorf("unable to get user %s, statusCode %d", userID, resp.StatusCode) + } + + return parseAuthentikUser(*user) +} + +// GetAccount returns all the users for a given profile. +func (am *AuthentikManager) GetAccount(accountID string) ([]*UserData, error) { + ctx, err := am.authenticationContext() + if err != nil { + return nil, err + } + + accountFilter := fmt.Sprintf("{%q:%q}", wtAccountID, accountID) + userList, resp, err := am.apiClient.CoreApi.CoreUsersList(ctx).Attributes(accountFilter).Execute() + if err != nil { + return nil, err + } + + if am.appMetrics != nil { + am.appMetrics.IDPMetrics().CountGetAccount() + } + + if resp.StatusCode != http.StatusOK { + if am.appMetrics != nil { + am.appMetrics.IDPMetrics().CountRequestStatusError() + } + return nil, fmt.Errorf("unable to get account %s users, statusCode %d", accountID, resp.StatusCode) + } + + users := make([]*UserData, 0) + for _, user := range userList.Results { + userData, err := parseAuthentikUser(user) + if err != nil { + return nil, err + } + users = append(users, userData) + } + + return users, nil +} + +// GetAllAccounts gets all registered accounts with corresponding user data. +// It returns a list of users indexed by accountID. +func (am *AuthentikManager) GetAllAccounts() (map[string][]*UserData, error) { + ctx, err := am.authenticationContext() + if err != nil { + return nil, err + } + + userList, resp, err := am.apiClient.CoreApi.CoreUsersList(ctx).Execute() + if err != nil { + return nil, err + } + + if am.appMetrics != nil { + am.appMetrics.IDPMetrics().CountGetAllAccounts() + } + + if resp.StatusCode != http.StatusOK { + if am.appMetrics != nil { + am.appMetrics.IDPMetrics().CountRequestStatusError() + } + return nil, fmt.Errorf("unable to get all accounts, statusCode %d", resp.StatusCode) + } + + indexedUsers := make(map[string][]*UserData) + for _, user := range userList.Results { + userData, err := parseAuthentikUser(user) + if err != nil { + return nil, err + } + + accountID := userData.AppMetadata.WTAccountID + if accountID != "" { + if _, ok := indexedUsers[accountID]; !ok { + indexedUsers[accountID] = make([]*UserData, 0) + } + indexedUsers[accountID] = append(indexedUsers[accountID], userData) + } + } + + return indexedUsers, nil +} + +// CreateUser creates a new user in authentik Idp and sends an invitation. +func (am *AuthentikManager) CreateUser(email string, name string, accountID string) (*UserData, error) { + ctx, err := am.authenticationContext() + if err != nil { + return nil, err + } + + groupID, err := am.getUserGroupByName("netbird") + if err != nil { + return nil, err + } + + defaultBoolValue := true + createUserRequest := api.UserRequest{ + Email: &email, + Name: name, + IsActive: &defaultBoolValue, + Groups: []string{groupID}, + Username: email, + Attributes: map[string]interface{}{ + wtAccountID: accountID, + wtPendingInvite: &defaultBoolValue, + }, + } + user, resp, err := am.apiClient.CoreApi.CoreUsersCreate(ctx).UserRequest(createUserRequest).Execute() + if err != nil { + return nil, err + } + + if am.appMetrics != nil { + am.appMetrics.IDPMetrics().CountCreateUser() + } + + if resp.StatusCode != http.StatusCreated { + if am.appMetrics != nil { + am.appMetrics.IDPMetrics().CountRequestStatusError() + } + return nil, fmt.Errorf("unable to create user, statusCode %d", resp.StatusCode) + } + + return parseAuthentikUser(*user) +} + +// GetUserByEmail searches users with a given email. +// If no users have been found, this function returns an empty list. +func (am *AuthentikManager) GetUserByEmail(email string) ([]*UserData, error) { + ctx, err := am.authenticationContext() + if err != nil { + return nil, err + } + + userList, resp, err := am.apiClient.CoreApi.CoreUsersList(ctx).Email(email).Execute() + if err != nil { + return nil, err + } + + if am.appMetrics != nil { + am.appMetrics.IDPMetrics().CountGetUserByEmail() + } + + if resp.StatusCode != http.StatusOK { + if am.appMetrics != nil { + am.appMetrics.IDPMetrics().CountRequestStatusError() + } + return nil, fmt.Errorf("unable to get user %s, statusCode %d", email, resp.StatusCode) + } + + users := make([]*UserData, 0) + for _, user := range userList.Results { + userData, err := parseAuthentikUser(user) + if err != nil { + return nil, err + } + users = append(users, userData) + } + + return users, nil +} + +func (am *AuthentikManager) authenticationContext() (context.Context, error) { + jwtToken, err := am.credentials.Authenticate() + if err != nil { + return nil, err + } + + value := map[string]api.APIKey{ + "authentik": { + Key: jwtToken.AccessToken, + Prefix: jwtToken.TokenType, + }, + } + return context.WithValue(context.Background(), api.ContextAPIKeys, value), nil +} + +// getUserGroupByName retrieves the user group for assigning new users. +// If the group is not found, a new group with the specified name will be created. +func (am *AuthentikManager) getUserGroupByName(name string) (string, error) { + ctx, err := am.authenticationContext() + if err != nil { + return "", err + } + + groupList, _, err := am.apiClient.CoreApi.CoreGroupsList(ctx).Name(name).Execute() + if err != nil { + return "", err + } + + if groupList != nil { + if len(groupList.Results) > 0 { + return groupList.Results[0].Pk, nil + } + } + + createGroupRequest := api.GroupRequest{Name: name} + group, resp, err := am.apiClient.CoreApi.CoreGroupsCreate(ctx).GroupRequest(createGroupRequest).Execute() + if err != nil { + return "", err + } + + if resp.StatusCode != http.StatusCreated { + return "", fmt.Errorf("unable to create user group, statusCode: %d", resp.StatusCode) + } + + return group.Pk, nil +} + +func parseAuthentikUser(user api.User) (*UserData, error) { + var attributes struct { + AccountID string `json:"wt_account_id"` + PendingInvite bool `json:"wt_pending_invite"` + } + + helper := JsonParser{} + buf, err := helper.Marshal(user.Attributes) + if err != nil { + return nil, err + } + + err = helper.Unmarshal(buf, &attributes) + if err != nil { + return nil, err + } + + return &UserData{ + Email: *user.Email, + Name: user.Name, + ID: strconv.FormatInt(int64(user.Pk), 10), + AppMetadata: AppMetadata{ + WTAccountID: attributes.AccountID, + WTPendingInvite: &attributes.PendingInvite, + }, + }, nil +} diff --git a/management/server/idp/authentik_test.go b/management/server/idp/authentik_test.go new file mode 100644 index 000000000..5cf8f2b2c --- /dev/null +++ b/management/server/idp/authentik_test.go @@ -0,0 +1,305 @@ +package idp + +import ( + "fmt" + "github.com/netbirdio/netbird/management/server/telemetry" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "io" + "strings" + "testing" + "time" +) + +func TestNewAuthentikManager(t *testing.T) { + type test struct { + name string + inputConfig AuthentikClientConfig + assertErrFunc require.ErrorAssertionFunc + assertErrFuncMessage string + } + + defaultTestConfig := AuthentikClientConfig{ + ClientID: "client_id", + Username: "username", + Password: "password", + TokenEndpoint: "https://localhost:8080/application/o/token/", + GrantType: "client_credentials", + } + + testCase1 := test{ + name: "Good Configuration", + inputConfig: defaultTestConfig, + assertErrFunc: require.NoError, + assertErrFuncMessage: "shouldn't return error", + } + + testCase2Config := defaultTestConfig + testCase2Config.ClientID = "" + + testCase2 := test{ + name: "Missing ClientID Configuration", + inputConfig: testCase2Config, + assertErrFunc: require.Error, + assertErrFuncMessage: "should return error when field empty", + } + + testCase3Config := defaultTestConfig + testCase3Config.Username = "" + + testCase3 := test{ + name: "Missing Username Configuration", + inputConfig: testCase3Config, + assertErrFunc: require.Error, + assertErrFuncMessage: "should return error when field empty", + } + + testCase4Config := defaultTestConfig + testCase4Config.Password = "" + + testCase4 := test{ + name: "Missing Password Configuration", + inputConfig: testCase4Config, + assertErrFunc: require.Error, + assertErrFuncMessage: "should return error when field empty", + } + + testCase5Config := defaultTestConfig + testCase5Config.GrantType = "" + + testCase5 := test{ + name: "Missing GrantType Configuration", + inputConfig: testCase5Config, + assertErrFunc: require.Error, + assertErrFuncMessage: "should return error when field empty", + } + + for _, testCase := range []test{testCase1, testCase2, testCase3, testCase4, testCase5} { + t.Run(testCase.name, func(t *testing.T) { + _, err := NewAuthentikManager(testCase.inputConfig, &telemetry.MockAppMetrics{}) + testCase.assertErrFunc(t, err, testCase.assertErrFuncMessage) + }) + } +} + +func TestAuthentikRequestJWTToken(t *testing.T) { + type requestJWTTokenTest struct { + name string + inputCode int + inputRespBody string + helper ManagerHelper + expectedFuncExitErrDiff error + expectedToken string + } + exp := 5 + token := newTestJWT(t, exp) + + requestJWTTokenTesttCase1 := requestJWTTokenTest{ + name: "Good JWT Response", + inputCode: 200, + inputRespBody: fmt.Sprintf("{\"access_token\":\"%s\",\"scope\":\"read:users\",\"expires_in\":%d,\"token_type\":\"Bearer\"}", token, exp), + helper: JsonParser{}, + expectedToken: token, + } + requestJWTTokenTestCase2 := requestJWTTokenTest{ + name: "Request Bad Status Code", + inputCode: 400, + inputRespBody: "{}", + helper: JsonParser{}, + expectedFuncExitErrDiff: fmt.Errorf("unable to get authentik token, statusCode 400"), + expectedToken: "", + } + + for _, testCase := range []requestJWTTokenTest{requestJWTTokenTesttCase1, requestJWTTokenTestCase2} { + t.Run(testCase.name, func(t *testing.T) { + + jwtReqClient := mockHTTPClient{ + resBody: testCase.inputRespBody, + code: testCase.inputCode, + } + config := AuthentikClientConfig{} + + creds := AuthentikCredentials{ + clientConfig: config, + httpClient: &jwtReqClient, + helper: testCase.helper, + } + + resp, err := creds.requestJWTToken() + if err != nil { + if testCase.expectedFuncExitErrDiff != nil { + assert.EqualError(t, err, testCase.expectedFuncExitErrDiff.Error(), "errors should be the same") + } else { + t.Fatal(err) + } + } else { + body, err := io.ReadAll(resp.Body) + assert.NoError(t, err, "unable to read the response body") + + jwtToken := JWTToken{} + err = testCase.helper.Unmarshal(body, &jwtToken) + assert.NoError(t, err, "unable to parse the json input") + + assert.Equalf(t, testCase.expectedToken, jwtToken.AccessToken, "two tokens should be the same") + } + }) + } +} + +func TestAuthentikParseRequestJWTResponse(t *testing.T) { + type parseRequestJWTResponseTest struct { + name string + inputRespBody string + helper ManagerHelper + expectedToken string + expectedExpiresIn int + assertErrFunc assert.ErrorAssertionFunc + assertErrFuncMessage string + } + + exp := 100 + token := newTestJWT(t, exp) + + parseRequestJWTResponseTestCase1 := parseRequestJWTResponseTest{ + name: "Parse Good JWT Body", + inputRespBody: fmt.Sprintf("{\"access_token\":\"%s\",\"scope\":\"read:users\",\"expires_in\":%d,\"token_type\":\"Bearer\"}", token, exp), + helper: JsonParser{}, + expectedToken: token, + expectedExpiresIn: exp, + assertErrFunc: assert.NoError, + assertErrFuncMessage: "no error was expected", + } + parseRequestJWTResponseTestCase2 := parseRequestJWTResponseTest{ + name: "Parse Bad json JWT Body", + inputRespBody: "", + helper: JsonParser{}, + expectedToken: "", + expectedExpiresIn: 0, + assertErrFunc: assert.Error, + assertErrFuncMessage: "json error was expected", + } + + for _, testCase := range []parseRequestJWTResponseTest{parseRequestJWTResponseTestCase1, parseRequestJWTResponseTestCase2} { + t.Run(testCase.name, func(t *testing.T) { + rawBody := io.NopCloser(strings.NewReader(testCase.inputRespBody)) + config := AuthentikClientConfig{} + + creds := AuthentikCredentials{ + clientConfig: config, + helper: testCase.helper, + } + jwtToken, err := creds.parseRequestJWTResponse(rawBody) + testCase.assertErrFunc(t, err, testCase.assertErrFuncMessage) + + assert.Equalf(t, testCase.expectedToken, jwtToken.AccessToken, "two tokens should be the same") + assert.Equalf(t, testCase.expectedExpiresIn, jwtToken.ExpiresIn, "the two expire times should be the same") + }) + } +} + +func TestAuthentikJwtStillValid(t *testing.T) { + type jwtStillValidTest struct { + name string + inputTime time.Time + expectedResult bool + message string + } + + jwtStillValidTestCase1 := jwtStillValidTest{ + name: "JWT still valid", + inputTime: time.Now().Add(10 * time.Second), + expectedResult: true, + message: "should be true", + } + jwtStillValidTestCase2 := jwtStillValidTest{ + name: "JWT is invalid", + inputTime: time.Now(), + expectedResult: false, + message: "should be false", + } + + for _, testCase := range []jwtStillValidTest{jwtStillValidTestCase1, jwtStillValidTestCase2} { + t.Run(testCase.name, func(t *testing.T) { + config := AuthentikClientConfig{} + + creds := AuthentikCredentials{ + clientConfig: config, + } + creds.jwtToken.expiresInTime = testCase.inputTime + + assert.Equalf(t, testCase.expectedResult, creds.jwtStillValid(), testCase.message) + }) + } +} + +func TestAuthentikAuthenticate(t *testing.T) { + type authenticateTest struct { + name string + inputCode int + inputResBody string + inputExpireToken time.Time + helper ManagerHelper + expectedFuncExitErrDiff error + expectedCode int + expectedToken string + } + exp := 5 + token := newTestJWT(t, exp) + + authenticateTestCase1 := authenticateTest{ + name: "Get Cached token", + inputExpireToken: time.Now().Add(30 * time.Second), + helper: JsonParser{}, + expectedFuncExitErrDiff: nil, + expectedCode: 200, + expectedToken: "", + } + + authenticateTestCase2 := authenticateTest{ + name: "Get Good JWT Response", + inputCode: 200, + inputResBody: fmt.Sprintf("{\"access_token\":\"%s\",\"scope\":\"read:users\",\"expires_in\":%d,\"token_type\":\"Bearer\"}", token, exp), + helper: JsonParser{}, + expectedCode: 200, + expectedToken: token, + } + + authenticateTestCase3 := authenticateTest{ + name: "Get Bad Status Code", + inputCode: 400, + inputResBody: "{}", + helper: JsonParser{}, + expectedFuncExitErrDiff: fmt.Errorf("unable to get authentik token, statusCode 400"), + expectedCode: 200, + expectedToken: "", + } + + for _, testCase := range []authenticateTest{authenticateTestCase1, authenticateTestCase2, authenticateTestCase3} { + t.Run(testCase.name, func(t *testing.T) { + + jwtReqClient := mockHTTPClient{ + resBody: testCase.inputResBody, + code: testCase.inputCode, + } + config := AuthentikClientConfig{} + + creds := AuthentikCredentials{ + clientConfig: config, + httpClient: &jwtReqClient, + helper: testCase.helper, + } + creds.jwtToken.expiresInTime = testCase.inputExpireToken + + _, err := creds.Authenticate() + if err != nil { + if testCase.expectedFuncExitErrDiff != nil { + assert.EqualError(t, err, testCase.expectedFuncExitErrDiff.Error(), "errors should be the same") + } else { + t.Fatal(err) + } + } + + assert.Equalf(t, testCase.expectedToken, creds.jwtToken.AccessToken, "two tokens should be the same") + }) + } +} diff --git a/management/server/idp/idp.go b/management/server/idp/idp.go index 489ea3ae9..79b945bc0 100644 --- a/management/server/idp/idp.go +++ b/management/server/idp/idp.go @@ -33,13 +33,14 @@ type ExtraConfig map[string]string // Config an idp configuration struct to be loaded from management server's config file type Config struct { - ManagerType string - ClientConfig *ClientConfig - ExtraConfig ExtraConfig - Auth0ClientCredentials Auth0ClientConfig - AzureClientCredentials AzureClientConfig - KeycloakClientCredentials KeycloakClientConfig - ZitadelClientCredentials ZitadelClientConfig + ManagerType string + ClientConfig *ClientConfig + ExtraConfig ExtraConfig + Auth0ClientCredentials Auth0ClientConfig + AzureClientCredentials AzureClientConfig + KeycloakClientCredentials KeycloakClientConfig + ZitadelClientCredentials ZitadelClientConfig + AuthentikClientCredentials AuthentikClientConfig } // ManagerCredentials interface that authenticates using the credential of each type of idp @@ -140,6 +141,21 @@ func NewManager(config Config, appMetrics telemetry.AppMetrics) (Manager, error) } return NewZitadelManager(zitadelClientConfig, appMetrics) + + case "authentik": + authentikConfig := config.AuthentikClientCredentials + if config.ClientConfig != nil { + authentikConfig = AuthentikClientConfig{ + Issuer: config.ClientConfig.Issuer, + ClientID: config.ClientConfig.ClientID, + TokenEndpoint: config.ClientConfig.TokenEndpoint, + GrantType: config.ClientConfig.GrantType, + Username: config.ExtraConfig["Username"], + Password: config.ExtraConfig["Password"], + } + } + + return NewAuthentikManager(authentikConfig, appMetrics) default: return nil, fmt.Errorf("invalid manager type: %s", config.ManagerType) }